├── .github └── workflows │ └── pr_validation.yml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── .gitignore ├── .metadata ├── README.md ├── analysis_options.yaml ├── lib │ ├── demos │ │ ├── demo_dropdown_list.dart │ │ ├── demo_dropdown_menu.dart │ │ ├── demo_popover_menu.dart │ │ ├── demo_popover_menu_bouncing_ball.dart │ │ ├── demo_popover_menu_draggable_ball.dart │ │ ├── demo_toolbar.dart │ │ ├── demo_toolbar_bouncing_ball.dart │ │ ├── demo_toolbar_draggable_ball.dart │ │ ├── demo_toolbar_moving_focal_point.dart │ │ ├── demo_toolbar_wide_draggable_ball.dart │ │ ├── demo_toolbar_with_scrolling_focal_point.dart │ │ └── inventory_demo.dart │ ├── infrastructure │ │ ├── ball_sandbox.dart │ │ └── button.dart │ └── main.dart ├── macos │ ├── .gitignore │ ├── Flutter │ │ ├── Flutter-Debug.xcconfig │ │ ├── Flutter-Release.xcconfig │ │ └── GeneratedPluginRegistrant.swift │ ├── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── Runner │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── app_icon_1024.png │ │ │ ├── app_icon_128.png │ │ │ ├── app_icon_16.png │ │ │ ├── app_icon_256.png │ │ │ ├── app_icon_32.png │ │ │ ├── app_icon_512.png │ │ │ └── app_icon_64.png │ │ ├── Base.lproj │ │ └── MainMenu.xib │ │ ├── Configs │ │ ├── AppInfo.xcconfig │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── Warnings.xcconfig │ │ ├── DebugProfile.entitlements │ │ ├── Info.plist │ │ ├── MainFlutterWindow.swift │ │ └── Release.entitlements ├── pubspec.lock └── pubspec.yaml ├── lib ├── follow_the_leader.dart ├── overlord.dart └── src │ ├── android │ └── text_overlays.dart │ ├── cupertino │ ├── cupertino_popover_aligners.dart │ ├── cupertino_popover_menu.dart │ ├── cupertino_toolbar.dart │ └── text_overlays.dart │ ├── hover.dart │ ├── logging.dart │ └── menus │ ├── menu_with_pointer.dart │ ├── menu_with_pointer_follower.dart │ ├── multi_level_menus.dart │ └── popovers.dart ├── pubspec.yaml ├── test ├── cupertino │ └── cupertino_popover_menu_test.dart └── menus │ ├── menu_path_test.dart │ └── popover_test.dart └── test_goldens └── cupertino ├── cupertino_popover_menu_test.dart └── goldens ├── cupertino-popover-menu ├── pointing-down-center-focal-point.png ├── pointing-down-left-focal-point-too-low.png ├── pointing-down-left-focal-point.png ├── pointing-down-right-focal-point-too-big.png ├── pointing-down-right-focal-point.png ├── pointing-left-bottom-focal-point-too-big.png ├── pointing-left-bottom-focal-point.png ├── pointing-left-center-focal-point.png ├── pointing-left-top-focal-point-too-low.png ├── pointing-left-top-focal-point.png ├── pointing-right-bottom-focal-point-too-big.png ├── pointing-right-bottom-focal-point.png ├── pointing-right-center-focal-point.png ├── pointing-right-top-focal-point-too-low.png ├── pointing-right-top-focal-point.png ├── pointing-up-center-focal-point.png ├── pointing-up-left-focal-point-too-low.png ├── pointing-up-left-focal-point.png ├── pointing-up-right-focal-point-too-big.png └── pointing-up-right-focal-point.png └── cupertino-toolbar ├── auto-paginated-page1.png ├── auto-paginated-page2.png ├── auto-paginated-page3.png ├── auto-paginated-page4.png ├── manual-pagination-page1.png ├── manual-pagination-page2.png ├── pointing-down-center-focal-point.png ├── pointing-down-left-focal-point-too-low.png ├── pointing-down-left-focal-point.png ├── pointing-down-right-focal-point-too-big.png ├── pointing-down-right-focal-point.png ├── pointing-up-center-focal-point.png ├── pointing-up-left-focal-point-too-low.png ├── pointing-up-left-focal-point.png ├── pointing-up-right-focal-point-too-big.png └── pointing-up-right-focal-point.png /.github/workflows/pr_validation.yml: -------------------------------------------------------------------------------- 1 | name: Run analysis and tests for pull requests 2 | on: [pull_request] 3 | jobs: 4 | analysis: 5 | runs-on: ubuntu-latest 6 | steps: 7 | # Checkout the PR branch 8 | - uses: actions/checkout@v3 9 | 10 | # Setup Flutter environment 11 | - uses: subosito/flutter-action@v2 12 | with: 13 | channel: "stable" 14 | 15 | # Download all the packages that the app uses 16 | - run: flutter pub get 17 | 18 | # Static analysis 19 | - run: flutter analyze 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | # Checkout the PR branch 25 | - uses: actions/checkout@v3 26 | 27 | # Setup Flutter environment 28 | - uses: subosito/flutter-action@v2 29 | with: 30 | channel: "stable" 31 | 32 | # Download all the packages that the app uses 33 | - run: flutter pub get 34 | 35 | # Run all tests 36 | - run: flutter test 37 | 38 | test_goldens: 39 | runs-on: macos-latest 40 | steps: 41 | # Checkout the PR branch 42 | - uses: actions/checkout@v3 43 | 44 | # Setup Flutter environment 45 | - uses: subosito/flutter-action@v2 46 | with: 47 | channel: "stable" 48 | architecture: x64 49 | 50 | # Download all the packages that the app uses 51 | - run: flutter pub get 52 | 53 | # Run all tests 54 | - run: flutter test test_goldens 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 26 | /pubspec.lock 27 | **/doc/api/ 28 | .dart_tool/ 29 | .packages 30 | build/ 31 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 8 | channel: beta 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.3+5 - Feb, 2024 2 | Added `PopoverScaffold` for building popover drop down lists. 3 | 4 | ## 0.0.3+4 - Sept, 2023 5 | Upgraded `follow_the_leader` to `0.0.4+5`. 6 | 7 | ## 0.0.3+3 - Sept, 2023 8 | Upgraded `follow_the_leader` to `0.0.4+4` and added a scrolling demo. 9 | 10 | ## 0.0.3+2 - April, 2023 11 | Better match for iOS popover and toolbar. 12 | 13 | * Popover and toolbar can extend content into the popover's arrow region. 14 | * Arrow icons replaced with chevrons. 15 | * Adjusted some colors. 16 | 17 | ## 0.0.3+1 - Jan, 2023 18 | Toolbar elevation, `follow_the_leader` update. 19 | 20 | * CupertinoPopoverToolbar and CupertinoPopoverMenu support elevation with shadows. 21 | * Upgraded `follow_the_leader` to v0.0.4+2 to get updated Follower scaling support. 22 | 23 | ## 0.0.3 - Jan, 2023 24 | Fidelity and nullability. 25 | 26 | * `LeaderMenuFocalPoint` now follows the global `Leader` offset, instead of the local `Leader` offset. 27 | * `LeaderMenuFocalPoint` now accounts for `Leader` scale when reporting focal point offset. 28 | * `CupertinoPopoverMenu` doesn't blow up when the focal point offset is null. 29 | 30 | ## 0.0.2+1 - Jan, 2023 31 | `CupertinoPopoverMenu` hit-test fix. 32 | 33 | * `CupertinoPopoverMenu` was hit-testing its paging buttons even when they were invisible. 34 | 35 | ## 0.0.2 - Dec, 2023 36 | Easier menu arrow orientation. 37 | 38 | * Replaced `globalFocalPoint` in `CupertinoPopoverToolbar` and `CupertinoPopoverMenu` with `focalPoint` of type `MenuFocalPoint`, which looks up the `Offset` on demand. 39 | * Added `LeaderMenuFocalPoint`, which gets its focal point offset from a `Leader` widget. 40 | 41 | ## 0.0.1 - Dec, 2022 42 | Initial Release. 43 | 44 | * `CupertinoPopoverToolbar` - an iOS-style popover toolbar with configurable buttons 45 | * `CupertinoPopoverMenu` - an iOS-style popover menu 46 | * `FollowerAligner`s are available to integrate Cupertino popovers with `follow_the_leader` for easy positioning 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Declarative, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Overlord - An assortment of widgets that loom over your content 3 |

4 | 5 |

6 | 7 | Built by the Flutter Bounty Hunters 8 | 9 |

10 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | # Additional information about this file can be found at 4 | # https://dart.dev/guides/language/analysis-options 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled. 5 | 6 | version: 7 | revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 8 | channel: beta 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 17 | base_revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 18 | - platform: macos 19 | create_revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 20 | base_revision: 75927305ff855f76a9ef704f9b4a86fa2fce7292 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) 13 | 14 | For help getting started with Flutter development, view the 15 | [online documentation](https://docs.flutter.dev/), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /example/lib/demos/demo_dropdown_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/button.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/services.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | /// Demo that shows how to use a [PopoverScaffold] to build a button that 7 | /// displays a dropdown list. 8 | class DropdownListDemo extends StatefulWidget { 9 | const DropdownListDemo({Key? key}) : super(key: key); 10 | 11 | @override 12 | State createState() => _DropdownListDemoState(); 13 | } 14 | 15 | class _DropdownListDemoState extends State { 16 | late final PopoverController _menuController; 17 | 18 | final _runConfigurations = [ 19 | RunConfiguration( 20 | key: GlobalKey(), 21 | icon: const FlutterLogo(size: 18), 22 | name: "Super Editor", 23 | ), 24 | RunConfiguration( 25 | key: GlobalKey(), 26 | icon: const FlutterLogo(size: 18), 27 | name: "Super Reader", 28 | ), 29 | RunConfiguration( 30 | key: GlobalKey(), 31 | icon: const FlutterLogo(size: 18), 32 | name: "Super Text Field", 33 | ), 34 | RunConfiguration( 35 | key: GlobalKey(), 36 | icon: const FlutterLogo(size: 18), 37 | name: "Docs", 38 | ), 39 | ]; 40 | 41 | RunConfiguration? _selectedConfiguration; 42 | 43 | @override 44 | void initState() { 45 | super.initState(); 46 | _menuController = PopoverController(); 47 | } 48 | 49 | @override 50 | void dispose() { 51 | _menuController.dispose(); 52 | super.dispose(); 53 | } 54 | 55 | void _onEditConfigurationSelected() { 56 | _menuController.close(); 57 | } 58 | 59 | void _onRunConfigurationSelected(RunConfiguration config) { 60 | _menuController.close(); 61 | 62 | setState(() { 63 | _selectedConfiguration = config; 64 | }); 65 | } 66 | 67 | @override 68 | Widget build(BuildContext context) { 69 | return Center( 70 | child: PopoverScaffold( 71 | controller: _menuController, 72 | buttonBuilder: (BuildContext context) { 73 | return AndroidStudioRunConfigDropdown( 74 | child: _RunConfigurationListItem( 75 | icon: _selectedConfiguration?.icon, 76 | label: _selectedConfiguration?.name ?? "None", 77 | isSelected: _selectedConfiguration != null, 78 | ), 79 | onPressed: () => _menuController.toggle(), 80 | ); 81 | }, 82 | popoverGeometry: const PopoverGeometry( 83 | aligner: MenuPopoverAligner(gap: Offset(0, 2)), 84 | ), 85 | popoverBuilder: (BuildContext context) { 86 | return AndroidStudioRunConfigurationList( 87 | runConfigurations: _runConfigurations, 88 | selectedConfiguration: _selectedConfiguration, 89 | onEditConfigurationsSelected: _onEditConfigurationSelected, 90 | onRunConfigurationSelected: _onRunConfigurationSelected, 91 | ); 92 | }, 93 | ), 94 | ); 95 | } 96 | } 97 | 98 | class RunConfiguration { 99 | const RunConfiguration({ 100 | required this.key, 101 | this.icon, 102 | required this.name, 103 | }); 104 | 105 | final GlobalKey key; 106 | final Widget? icon; 107 | final String name; 108 | } 109 | 110 | class AndroidStudioRunConfigDropdown extends StatelessWidget { 111 | const AndroidStudioRunConfigDropdown({ 112 | super.key, 113 | this.onPressed, 114 | required this.child, 115 | }); 116 | 117 | final VoidCallback? onPressed; 118 | final Widget child; 119 | 120 | @override 121 | Widget build(BuildContext context) { 122 | return Button( 123 | padding: const EdgeInsets.only(left: 0, top: 2, bottom: 2, right: 2), 124 | background: const Color(0xFF2F2F2F), 125 | backgroundOnHover: const Color(0xFF333333), 126 | backgroundOnPress: const Color(0xFF353535), 127 | border: BorderSide(color: Colors.white.withOpacity(0.1), width: 1), 128 | borderRadius: BorderRadius.circular(4), 129 | onPressed: onPressed, 130 | child: Row( 131 | mainAxisSize: MainAxisSize.min, 132 | crossAxisAlignment: CrossAxisAlignment.center, 133 | children: [ 134 | child, 135 | const Icon(Icons.arrow_drop_down), 136 | ], 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | class AndroidStudioRunConfigurationList extends StatefulWidget { 143 | const AndroidStudioRunConfigurationList({ 144 | super.key, 145 | required this.runConfigurations, 146 | this.selectedConfiguration, 147 | required this.onEditConfigurationsSelected, 148 | required this.onRunConfigurationSelected, 149 | }); 150 | 151 | final List runConfigurations; 152 | 153 | final RunConfiguration? selectedConfiguration; 154 | 155 | final VoidCallback onEditConfigurationsSelected; 156 | 157 | final void Function(RunConfiguration runConfiguration) onRunConfigurationSelected; 158 | 159 | @override 160 | State createState() => _AndroidStudioRunConfigurationListState(); 161 | } 162 | 163 | class _AndroidStudioRunConfigurationListState extends State { 164 | final _listFocusNode = FocusNode(); 165 | final _editConfigurationsKey = GlobalKey(); 166 | 167 | late GlobalKey _activeItemKey; 168 | 169 | @override 170 | void initState() { 171 | super.initState(); 172 | 173 | _listFocusNode.requestFocus(); 174 | 175 | _activeItemKey = _editConfigurationsKey; 176 | } 177 | 178 | @override 179 | void dispose() { 180 | _listFocusNode.dispose(); 181 | super.dispose(); 182 | } 183 | 184 | KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { 185 | if (event is! KeyDownEvent && event is! KeyRepeatEvent) { 186 | return KeyEventResult.ignored; 187 | } 188 | 189 | if (!const [LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.enter] 190 | .contains(event.logicalKey)) { 191 | return KeyEventResult.ignored; 192 | } 193 | 194 | int activeIndex = _activeIndex; 195 | 196 | if (event.logicalKey == LogicalKeyboardKey.arrowUp && activeIndex > 0) { 197 | setState(() { 198 | _activateItemAt(activeIndex - 1); 199 | }); 200 | return KeyEventResult.handled; 201 | } 202 | 203 | if (event.logicalKey == LogicalKeyboardKey.arrowDown && activeIndex < _listLength - 1) { 204 | setState(() { 205 | _activateItemAt(activeIndex + 1); 206 | }); 207 | return KeyEventResult.handled; 208 | } 209 | 210 | if (event.logicalKey == LogicalKeyboardKey.enter) { 211 | if (activeIndex == 0) { 212 | widget.onEditConfigurationsSelected(); 213 | } else { 214 | widget.onRunConfigurationSelected(widget.runConfigurations[activeIndex - 1]); 215 | } 216 | } 217 | 218 | return KeyEventResult.ignored; 219 | } 220 | 221 | int get _activeIndex => _activeItemKey == _editConfigurationsKey // 222 | ? 0 223 | : widget.runConfigurations.indexWhere((element) => element.key == _activeItemKey) + 1; 224 | 225 | int get _listLength => widget.runConfigurations.length + 1; 226 | 227 | void _activateItemAt(int index) { 228 | if (index == 0) { 229 | _activeItemKey = _editConfigurationsKey; 230 | return; 231 | } 232 | 233 | _activeItemKey = widget.runConfigurations[index - 1].key; 234 | } 235 | 236 | @override 237 | Widget build(BuildContext context) { 238 | return Focus( 239 | focusNode: _listFocusNode, 240 | onKeyEvent: _onKeyEvent, 241 | child: Container( 242 | width: 200, 243 | padding: const EdgeInsets.symmetric(vertical: 4), 244 | decoration: BoxDecoration( 245 | color: const Color(0xFF2F2F2F), 246 | borderRadius: BorderRadius.circular(4), 247 | border: Border.all(color: Colors.white.withOpacity(0.1), width: 1), 248 | ), 249 | child: Column( 250 | mainAxisSize: MainAxisSize.min, 251 | crossAxisAlignment: CrossAxisAlignment.stretch, 252 | children: [ 253 | AndroidStudioRunConfigurationListItem( 254 | key: _editConfigurationsKey, 255 | isActive: _activeItemKey == _editConfigurationsKey, 256 | onHoverEnter: () { 257 | setState(() { 258 | _activeItemKey = _editConfigurationsKey; 259 | }); 260 | }, 261 | onPressed: widget.onEditConfigurationsSelected, 262 | child: const Text( 263 | "Edit Configurations...", 264 | textAlign: TextAlign.center, 265 | ), 266 | ), 267 | const Divider(), 268 | Padding( 269 | padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4), 270 | child: Text( 271 | "Run Configurations", 272 | style: TextStyle( 273 | color: Colors.white.withOpacity(0.3), 274 | fontWeight: FontWeight.bold, 275 | ), 276 | ), 277 | ), 278 | for (final config in widget.runConfigurations) 279 | AndroidStudioRunConfigurationListItem( 280 | key: config.key, 281 | isActive: _activeItemKey == config.key, 282 | onHoverEnter: () { 283 | setState(() { 284 | _activeItemKey = config.key; 285 | }); 286 | }, 287 | onPressed: () => widget.onRunConfigurationSelected(config), 288 | child: _RunConfigurationListItem( 289 | icon: config.icon, 290 | label: config.name, 291 | isSelected: widget.selectedConfiguration == config, 292 | ), 293 | ), 294 | ], 295 | ), 296 | ), 297 | ); 298 | } 299 | } 300 | 301 | class AndroidStudioRunConfigurationListItem extends StatelessWidget { 302 | const AndroidStudioRunConfigurationListItem({ 303 | super.key, 304 | required this.isActive, 305 | this.onHoverEnter, 306 | this.onHoverExit, 307 | this.onPressed, 308 | required this.child, 309 | }); 310 | 311 | /// Whether this list item is currently the active item in the list, e.g., 312 | /// the item that's focused, or hovered, and should be visually selected. 313 | final bool isActive; 314 | 315 | final VoidCallback? onHoverEnter; 316 | 317 | final VoidCallback? onHoverExit; 318 | 319 | final VoidCallback? onPressed; 320 | 321 | final Widget child; 322 | 323 | @override 324 | Widget build(BuildContext context) { 325 | return MouseRegion( 326 | cursor: SystemMouseCursors.click, 327 | onEnter: (_) => onHoverEnter?.call(), 328 | onExit: (_) => onHoverExit?.call(), 329 | child: GestureDetector( 330 | onTap: onPressed, 331 | child: Container( 332 | color: isActive ? Colors.blue : Colors.transparent, 333 | padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), 334 | child: child, 335 | ), 336 | ), 337 | ); 338 | } 339 | } 340 | 341 | class _RunConfigurationListItem extends StatelessWidget { 342 | const _RunConfigurationListItem({ 343 | this.icon, 344 | required this.label, 345 | this.isSelected = false, 346 | }); 347 | 348 | final Widget? icon; 349 | final String label; 350 | final bool isSelected; 351 | 352 | @override 353 | Widget build(BuildContext context) { 354 | return Row( 355 | mainAxisSize: MainAxisSize.min, 356 | crossAxisAlignment: CrossAxisAlignment.center, 357 | children: [ 358 | const SizedBox(width: 4), 359 | if (icon != null) // 360 | Stack( 361 | clipBehavior: Clip.none, 362 | children: [ 363 | icon!, 364 | if (isSelected) // 365 | Positioned.fill( 366 | child: Align( 367 | alignment: const Alignment(1.1, 1.1), 368 | child: Container( 369 | width: 5, 370 | height: 5, 371 | decoration: const BoxDecoration( 372 | shape: BoxShape.circle, 373 | color: Colors.green, 374 | ), 375 | ), 376 | ), 377 | ), 378 | ], 379 | ), 380 | const SizedBox(width: 6), 381 | Text(label), 382 | ], 383 | ); 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /example/lib/demos/demo_dropdown_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/button.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/services.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | /// Demo that shows how to use a [PopoverScaffold] to build a button that 7 | /// displays a multi-level dropdown menu. 8 | class DropdownMenuDemo extends StatefulWidget { 9 | const DropdownMenuDemo({Key? key}) : super(key: key); 10 | 11 | @override 12 | State createState() => _DropdownMenuDemoState(); 13 | } 14 | 15 | class _DropdownMenuDemoState extends State { 16 | final _menuController = MultiLevelMenuController(); 17 | 18 | @override 19 | void dispose() { 20 | _menuController.dispose(); 21 | super.dispose(); 22 | } 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return Align( 27 | alignment: const Alignment(-0.75, -0.8), 28 | child: Row( 29 | mainAxisSize: MainAxisSize.min, 30 | children: [ 31 | AppButton( 32 | menuController: _menuController, 33 | ), 34 | MenuButton( 35 | isMenuOpen: false, 36 | onPressed: () {}, 37 | child: const Text('Edit'), 38 | ), 39 | MenuButton( 40 | isMenuOpen: false, 41 | onPressed: () {}, 42 | child: const Text('View'), 43 | ), 44 | MenuButton( 45 | isMenuOpen: false, 46 | onPressed: () {}, 47 | child: const Text('Window'), 48 | ), 49 | MenuButton( 50 | isMenuOpen: false, 51 | onPressed: () {}, 52 | child: const Text('Help'), 53 | ), 54 | ], 55 | ), 56 | ); 57 | } 58 | } 59 | 60 | class AppButton extends StatefulWidget { 61 | const AppButton({ 62 | super.key, 63 | required this.menuController, 64 | }); 65 | 66 | final MultiLevelMenuController menuController; 67 | 68 | @override 69 | State createState() => _AppButtonState(); 70 | } 71 | 72 | class _AppButtonState extends State { 73 | late final PopoverController _popoverController; 74 | 75 | @override 76 | void initState() { 77 | super.initState(); 78 | _popoverController = PopoverController(); 79 | } 80 | 81 | @override 82 | void didUpdateWidget(AppButton oldWidget) { 83 | super.didUpdateWidget(oldWidget); 84 | } 85 | 86 | @override 87 | void dispose() { 88 | _popoverController.dispose(); 89 | super.dispose(); 90 | } 91 | 92 | void _onMenuItemPressed(MenuItem menuItem) { 93 | _popoverController.close(); 94 | } 95 | 96 | @override 97 | Widget build(BuildContext context) { 98 | return PopoverScaffold( 99 | controller: _popoverController, 100 | buttonBuilder: (BuildContext context) { 101 | return ListenableBuilder( 102 | listenable: _popoverController, 103 | builder: (context, child) { 104 | return MenuButton( 105 | isMenuOpen: _popoverController.shouldShow, 106 | onPressed: () => _popoverController.toggle(), 107 | child: const Text('File'), 108 | ); 109 | }, 110 | ); 111 | }, 112 | popoverGeometry: const PopoverGeometry( 113 | aligner: MenuPopoverAligner(gap: Offset.zero), 114 | ), 115 | popoverBuilder: (BuildContext context) { 116 | return MenuList( 117 | menuController: widget.menuController, 118 | menu: _fileMenu, 119 | onMenuItemSelected: _onMenuItemPressed, 120 | ); 121 | }, 122 | ); 123 | } 124 | } 125 | 126 | class MenuButton extends StatelessWidget { 127 | const MenuButton({ 128 | super.key, 129 | required this.isMenuOpen, 130 | this.onPressed, 131 | required this.child, 132 | }); 133 | 134 | final bool isMenuOpen; 135 | final VoidCallback? onPressed; 136 | final Widget child; 137 | 138 | @override 139 | Widget build(BuildContext context) { 140 | return Button( 141 | padding: const EdgeInsets.only(left: 12, top: 4, bottom: 6, right: 12), 142 | background: isMenuOpen ? const Color(0xFF333333) : Colors.transparent, 143 | backgroundOnHover: const Color(0xFF333333), 144 | backgroundOnPress: const Color(0xFF353535), 145 | borderRadius: BorderRadius.circular(2), 146 | onPressed: onPressed, 147 | child: child, 148 | ); 149 | } 150 | } 151 | 152 | class MenuList extends StatefulWidget { 153 | const MenuList({ 154 | super.key, 155 | required this.menuController, 156 | required this.menu, 157 | required this.onMenuItemSelected, 158 | }); 159 | 160 | final MultiLevelMenuController menuController; 161 | final MenuGroup menu; 162 | 163 | final void Function(MenuItem menuItem) onMenuItemSelected; 164 | 165 | @override 166 | State createState() => _MenuListState(); 167 | } 168 | 169 | class _MenuListState extends State { 170 | final _listFocusNode = FocusNode(); 171 | 172 | final _menuItemsToKeys = {}; 173 | final _keysToMenuItems = {}; 174 | final _keysInOrder = []; 175 | 176 | GlobalKey? _activeItemKey; 177 | 178 | @override 179 | void initState() { 180 | super.initState(); 181 | 182 | _listFocusNode.requestFocus(); 183 | 184 | _updateGlobalKeyMaps(); 185 | } 186 | 187 | @override 188 | void didUpdateWidget(MenuList oldWidget) { 189 | super.didUpdateWidget(oldWidget); 190 | 191 | if (widget.menu != oldWidget.menu) { 192 | _updateGlobalKeyMaps(); 193 | } 194 | } 195 | 196 | @override 197 | void dispose() { 198 | _listFocusNode.dispose(); 199 | super.dispose(); 200 | } 201 | 202 | void _updateGlobalKeyMaps() { 203 | _menuItemsToKeys.clear(); 204 | _keysToMenuItems.clear(); 205 | 206 | for (final group in widget.menu.groupedItems) { 207 | for (final item in group) { 208 | final key = GlobalKey(debugLabel: item.id); 209 | _menuItemsToKeys[item.id] = key; 210 | _keysToMenuItems[key] = item; 211 | _keysInOrder.add(key); 212 | } 213 | } 214 | } 215 | 216 | KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { 217 | if (event is! KeyDownEvent && event is! KeyRepeatEvent) { 218 | return KeyEventResult.ignored; 219 | } 220 | 221 | if (!const [LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.enter] 222 | .contains(event.logicalKey)) { 223 | return KeyEventResult.ignored; 224 | } 225 | 226 | int activeIndex = _activeIndex; 227 | 228 | if (event.logicalKey == LogicalKeyboardKey.arrowUp && activeIndex > 0) { 229 | setState(() { 230 | _activateItemAt(activeIndex - 1); 231 | }); 232 | return KeyEventResult.handled; 233 | } 234 | 235 | if (event.logicalKey == LogicalKeyboardKey.arrowDown && activeIndex < widget.menu.length - 1) { 236 | setState(() { 237 | _activateItemAt(activeIndex + 1); 238 | }); 239 | return KeyEventResult.handled; 240 | } 241 | 242 | if (event.logicalKey == LogicalKeyboardKey.enter) { 243 | widget.onMenuItemSelected(widget.menu.getItemAt(activeIndex)); 244 | } 245 | 246 | return KeyEventResult.ignored; 247 | } 248 | 249 | int get _activeIndex => _activeItemKey != null ? _keysInOrder.indexOf(_activeItemKey!) : -1; 250 | 251 | void _activateItemAt(int index) { 252 | _activeItemKey = _menuItemsToKeys[widget.menu.getItemAt(index)]!; 253 | } 254 | 255 | @override 256 | Widget build(BuildContext context) { 257 | return Focus( 258 | focusNode: _listFocusNode, 259 | onKeyEvent: _onKeyEvent, 260 | child: Container( 261 | width: 275, 262 | padding: const EdgeInsets.symmetric(vertical: 4), 263 | decoration: BoxDecoration( 264 | color: const Color(0xFF2F2F2F), 265 | borderRadius: BorderRadius.circular(4), 266 | border: Border.all(color: Colors.white.withOpacity(0.1), width: 1), 267 | ), 268 | child: SingleChildScrollView( 269 | child: Column( 270 | mainAxisSize: MainAxisSize.min, 271 | crossAxisAlignment: CrossAxisAlignment.stretch, 272 | children: [ 273 | for (final group in widget.menu.groupedItems) ...[ 274 | for (final item in group) 275 | MenuListItem( 276 | key: _menuItemsToKeys[item.id], 277 | menuController: widget.menuController, 278 | menuItem: item, 279 | isActive: _activeItemKey == _menuItemsToKeys[item.id], 280 | onHoverEnter: () { 281 | setState(() { 282 | widget.menuController.show(item.path); 283 | _activeItemKey = _menuItemsToKeys[item.id]; 284 | }); 285 | }, 286 | onPressed: () => widget.onMenuItemSelected(item), 287 | ), 288 | if (group != widget.menu.groupedItems.last) // 289 | const Divider(), 290 | ], 291 | ], 292 | ), 293 | ), 294 | ), 295 | ); 296 | } 297 | } 298 | 299 | class MenuListItem extends StatefulWidget { 300 | const MenuListItem({ 301 | super.key, 302 | required this.menuController, 303 | required this.menuItem, 304 | required this.isActive, 305 | this.onHoverEnter, 306 | this.onHoverExit, 307 | this.onPressed, 308 | }); 309 | 310 | final MultiLevelMenuController menuController; 311 | 312 | final MenuItem menuItem; 313 | 314 | /// Whether this list item is currently the active item in the list, e.g., 315 | /// the item that's focused, or hovered, and should be visually selected. 316 | final bool isActive; 317 | 318 | final VoidCallback? onHoverEnter; 319 | 320 | final VoidCallback? onHoverExit; 321 | 322 | final VoidCallback? onPressed; 323 | 324 | @override 325 | State createState() => _MenuListItemState(); 326 | } 327 | 328 | class _MenuListItemState extends State { 329 | late final PopoverController _popoverController; 330 | 331 | @override 332 | void initState() { 333 | super.initState(); 334 | _popoverController = PopoverController(); 335 | 336 | widget.menuController.addListener(_onMenuChange); 337 | } 338 | 339 | @override 340 | void didUpdateWidget(MenuListItem oldWidget) { 341 | super.didUpdateWidget(oldWidget); 342 | 343 | if (widget.menuController != oldWidget.menuController) { 344 | oldWidget.menuController.removeListener(_onMenuChange); 345 | widget.menuController.addListener(_onMenuChange); 346 | } 347 | } 348 | 349 | @override 350 | void dispose() { 351 | widget.menuController.removeListener(_onMenuChange); 352 | 353 | _popoverController.dispose(); 354 | super.dispose(); 355 | } 356 | 357 | void _onMenuChange() { 358 | if (widget.menuItem.subMenu != null && 359 | widget.menuController.visiblePath?.containsPath(widget.menuItem.path) == true) { 360 | _popoverController.open(); 361 | } else { 362 | _popoverController.close(); 363 | } 364 | } 365 | 366 | void _onHoverEnter() { 367 | widget.onHoverEnter?.call(); 368 | } 369 | 370 | void _onMenuItemPressed(MenuItem menuItem) {} 371 | 372 | @override 373 | Widget build(BuildContext context) { 374 | return PopoverScaffold( 375 | controller: _popoverController, 376 | buttonBuilder: (BuildContext context) { 377 | return ListenableBuilder( 378 | listenable: _popoverController, 379 | builder: (context, child) { 380 | return _buildListItem(); 381 | }, 382 | ); 383 | }, 384 | popoverGeometry: const PopoverGeometry( 385 | aligner: MenuPopoverAligner( 386 | contentAnchor: Alignment.topRight, 387 | popoverAnchor: Alignment.topLeft, 388 | gap: Offset(-4, 0), 389 | ), 390 | ), 391 | popoverBuilder: (BuildContext context) { 392 | return MenuList( 393 | menuController: widget.menuController, 394 | menu: widget.menuItem.subMenu!, 395 | onMenuItemSelected: _onMenuItemPressed, 396 | ); 397 | }, 398 | ); 399 | } 400 | 401 | Widget _buildListItem() { 402 | return MouseRegion( 403 | cursor: SystemMouseCursors.click, 404 | onEnter: (_) => _onHoverEnter(), 405 | onExit: (_) => widget.onHoverExit?.call(), 406 | child: GestureDetector( 407 | onTap: widget.onPressed, 408 | child: Container( 409 | color: widget.isActive ? const Color(0xFF444444) : Colors.transparent, 410 | padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 411 | child: IconTheme( 412 | data: IconTheme.of(context).copyWith(size: 18), 413 | child: Row( 414 | children: [ 415 | if (widget.menuItem.icon != null) ...[ 416 | widget.menuItem.icon!, 417 | const SizedBox(width: 12), 418 | ], 419 | Text(widget.menuItem.label), 420 | const Spacer(), 421 | if (widget.menuItem.shortcut != null) // 422 | Text( 423 | widget.menuItem.shortcut!, 424 | style: TextStyle( 425 | color: Colors.white.withOpacity(0.3), 426 | fontWeight: FontWeight.bold, 427 | ), 428 | ), 429 | if (widget.menuItem.subMenu != null) // 430 | Icon(Icons.arrow_right, size: 18, color: Colors.white.withOpacity(0.3)), 431 | ], 432 | ), 433 | ), 434 | ), 435 | ), 436 | ); 437 | } 438 | } 439 | 440 | const _fileMenu = MenuGroup(groupedItems: [ 441 | [ 442 | MenuItem( 443 | id: "new", 444 | path: MenuPath(["file", "new"]), 445 | icon: Icon(Icons.feed), 446 | label: "New", 447 | subMenu: MenuGroup( 448 | groupedItems: [ 449 | [ 450 | MenuItem( 451 | id: "document", 452 | path: MenuPath(["file", "new", "document"]), 453 | icon: Icon(Icons.feed, color: Colors.blue), 454 | label: "Document", 455 | subMenu: MenuGroup( 456 | groupedItems: [ 457 | [ 458 | MenuItem( 459 | id: "blog", 460 | path: MenuPath(["file", "new", "document", "blog"]), 461 | icon: Icon(Icons.article_outlined), 462 | label: "Blog", 463 | ), 464 | MenuItem( 465 | id: "news", 466 | path: MenuPath(["file", "new", "document", "news"]), 467 | icon: Icon(Icons.article_outlined), 468 | label: "News", 469 | ), 470 | ], 471 | ], 472 | ), 473 | ), 474 | MenuItem( 475 | id: "from_template_gallery", 476 | path: MenuPath(["file", "new", "from_template_gallery"]), 477 | icon: Icon(Icons.browse_gallery_outlined), 478 | label: "From template gallery", 479 | ), 480 | ], 481 | ], 482 | ), 483 | ), 484 | MenuItem( 485 | id: "open", 486 | path: MenuPath(["file", "open"]), 487 | icon: Icon(Icons.folder_open), 488 | label: "Open", 489 | shortcut: "⌘O", 490 | ), 491 | MenuItem( 492 | id: "make_a_copy", 493 | path: MenuPath(["file", "make_a_copy"]), 494 | icon: Icon(Icons.copy), 495 | label: "Make a copy", 496 | ), 497 | ], 498 | [ 499 | MenuItem( 500 | id: "share", 501 | path: MenuPath(["file", "share"]), 502 | icon: Icon(Icons.person_add_alt), 503 | label: "Share", 504 | subMenu: MenuGroup( 505 | groupedItems: [ 506 | [ 507 | MenuItem( 508 | id: "share_with_others", 509 | path: MenuPath(["file", "share", "share_with_others"]), 510 | icon: Icon(Icons.person_add_alt), 511 | label: "Share with others", 512 | ), 513 | MenuItem( 514 | id: "publish_to_web", 515 | path: MenuPath(["file", "share", "publish_to_web"]), 516 | icon: Icon(Icons.public_sharp), 517 | label: "Publish to web", 518 | ), 519 | ], 520 | ], 521 | ), 522 | ), 523 | MenuItem( 524 | id: "email", 525 | path: MenuPath(["email"]), 526 | icon: Icon(Icons.email_outlined), 527 | label: "Email", 528 | ), 529 | MenuItem( 530 | id: "download", 531 | path: MenuPath(["download"]), 532 | icon: Icon(Icons.download_outlined), 533 | label: "Download", 534 | ), 535 | ], 536 | [ 537 | MenuItem( 538 | id: "rename", 539 | path: MenuPath(["rename"]), 540 | icon: Icon(Icons.drive_file_rename_outline), 541 | label: "Rename", 542 | ), 543 | MenuItem( 544 | id: "move", 545 | path: MenuPath(["move"]), 546 | icon: Icon(Icons.drive_file_move_outline), 547 | label: "Move", 548 | ), 549 | MenuItem( 550 | id: "add_shortcut_to_drive", 551 | path: MenuPath(["add_shortcut_to_drive"]), 552 | icon: Icon(Icons.add_to_drive), 553 | label: "Add shortcut to drive", 554 | ), 555 | MenuItem( 556 | id: "move_to_trash", 557 | path: MenuPath(["move_to_trash"]), 558 | icon: Icon(Icons.delete), 559 | label: "Move to trash", 560 | ), 561 | ], 562 | [ 563 | MenuItem( 564 | id: "version_history", 565 | path: MenuPath(["version_history"]), 566 | icon: Icon(Icons.history), 567 | label: "Version history", 568 | ), 569 | MenuItem( 570 | id: "make_available_offline", 571 | path: MenuPath(["make_available_offline"]), 572 | icon: Icon(Icons.offline_pin_outlined), 573 | label: "Make available offline", 574 | ), 575 | ], 576 | [ 577 | MenuItem( 578 | id: "details", 579 | path: MenuPath(["details"]), 580 | icon: Icon(Icons.info), 581 | label: "Details", 582 | ), 583 | MenuItem( 584 | id: "language", 585 | path: MenuPath(["language"]), 586 | icon: Icon(Icons.language), 587 | label: "Language", 588 | ), 589 | MenuItem( 590 | id: "page_setup", 591 | path: MenuPath(["page_setup"]), 592 | icon: Icon(Icons.contact_page_outlined), 593 | label: "Page setup", 594 | ), 595 | MenuItem( 596 | id: "print", 597 | path: MenuPath(["print"]), 598 | icon: Icon(Icons.print), 599 | label: "Print", 600 | shortcut: "⌘P", 601 | ), 602 | ], 603 | ]); 604 | 605 | class MenuGroup { 606 | const MenuGroup({ 607 | required this.groupedItems, 608 | }); 609 | 610 | final List> groupedItems; 611 | 612 | MenuItem getItemAt(int index) { 613 | assert(index >= 0); 614 | assert(index < length); 615 | 616 | int countToGo = index; 617 | for (final group in groupedItems) { 618 | if (group.length > countToGo) { 619 | return group[countToGo]; 620 | } 621 | 622 | countToGo -= group.length; 623 | } 624 | 625 | throw Exception("Couldn't find list item $index in groupedItems: $groupedItems"); 626 | } 627 | 628 | int get length => groupedItems.fold(0, (count, group) => count + group.length); 629 | } 630 | 631 | class MenuItem { 632 | const MenuItem({ 633 | required this.id, 634 | required this.path, 635 | this.subMenu, 636 | this.icon, 637 | required this.label, 638 | this.shortcut, 639 | this.isEnabled = true, 640 | }); 641 | 642 | final String id; 643 | final MenuPath path; 644 | final MenuGroup? subMenu; 645 | 646 | final Widget? icon; 647 | final String label; 648 | final String? shortcut; 649 | final bool isEnabled; 650 | } 651 | -------------------------------------------------------------------------------- /example/lib/demos/demo_popover_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:overlord/overlord.dart'; 3 | 4 | /// Demo which shows the capabilities of the [CupertinoPopoverMenu]. 5 | /// 6 | /// This demo includes examples of the popover pointing up, down, left and right. 7 | /// 8 | /// It also includes a draggable example, where the user can drag the popover around the screen 9 | /// and the popover updates the arrow direction to always point to the focal point. 10 | class PopoverDemo extends StatefulWidget { 11 | const PopoverDemo({Key? key}) : super(key: key); 12 | 13 | @override 14 | State createState() => _PopoverDemoState(); 15 | } 16 | 17 | class _PopoverDemoState extends State { 18 | late List items; 19 | late PopoverDemoItem _selectedItem; 20 | 21 | @override 22 | void initState() { 23 | super.initState(); 24 | items = [ 25 | PopoverDemoItem( 26 | label: 'Pointing Up', 27 | builder: (context) => const PopoverExample( 28 | focalPoint: Offset(500, 0), 29 | ), 30 | ), 31 | PopoverDemoItem( 32 | label: 'Pointing Down', 33 | builder: (context) => const PopoverExample( 34 | focalPoint: Offset(500, 1000), 35 | ), 36 | ), 37 | PopoverDemoItem( 38 | label: 'Pointing Left', 39 | builder: (context) => const PopoverExample( 40 | focalPoint: Offset(0, 334), 41 | ), 42 | ), 43 | PopoverDemoItem( 44 | label: 'Pointing Right', 45 | builder: (context) => const PopoverExample( 46 | focalPoint: Offset(1000, 334), 47 | ), 48 | ), 49 | PopoverDemoItem( 50 | label: 'Draggable', 51 | builder: (context) => const DraggableDemo( 52 | focalPoint: Offset(500, 334), 53 | ), 54 | ), 55 | ]; 56 | _selectedItem = items.first; 57 | } 58 | 59 | @override 60 | Widget build(BuildContext context) { 61 | return SizedBox.expand( 62 | child: Row( 63 | children: [ 64 | Expanded( 65 | child: _selectedItem.builder(context), 66 | ), 67 | Container( 68 | color: Colors.redAccent, 69 | height: double.infinity, 70 | width: 250, 71 | child: SingleChildScrollView( 72 | child: Padding( 73 | padding: const EdgeInsets.all(16), 74 | child: Column( 75 | children: [ 76 | const SizedBox(height: 48), 77 | for (final item in items) ...[ 78 | _buildDemoButton(item), 79 | const SizedBox(height: 24), 80 | ] 81 | ], 82 | ), 83 | ), 84 | ), 85 | ), 86 | ], 87 | ), 88 | ); 89 | } 90 | 91 | Widget _buildDemoButton(PopoverDemoItem item) { 92 | return SizedBox( 93 | width: double.infinity, 94 | child: ElevatedButton( 95 | onPressed: () { 96 | setState(() { 97 | _selectedItem = item; 98 | }); 99 | }, 100 | style: ElevatedButton.styleFrom(backgroundColor: Colors.red), 101 | child: Text(item.label), 102 | ), 103 | ); 104 | } 105 | } 106 | 107 | class PopoverDemoItem { 108 | final String label; 109 | final WidgetBuilder builder; 110 | 111 | PopoverDemoItem({ 112 | required this.label, 113 | required this.builder, 114 | }); 115 | } 116 | 117 | class DraggableDemo extends StatefulWidget { 118 | const DraggableDemo({ 119 | super.key, 120 | required this.focalPoint, 121 | }); 122 | 123 | final Offset focalPoint; 124 | 125 | @override 126 | State createState() => _DraggableDemoState(); 127 | } 128 | 129 | class _DraggableDemoState extends State { 130 | Offset _offset = const Offset(50, 50); 131 | 132 | void _onPanUpdate(DragUpdateDetails details) { 133 | setState(() { 134 | _offset += details.delta; 135 | }); 136 | } 137 | 138 | @override 139 | Widget build(BuildContext context) { 140 | return Stack( 141 | children: [ 142 | Positioned( 143 | left: widget.focalPoint.dx, 144 | top: widget.focalPoint.dy, 145 | child: Container( 146 | color: Colors.red, 147 | height: 10, 148 | width: 10, 149 | ), 150 | ), 151 | Positioned( 152 | left: _offset.dx, 153 | top: _offset.dy, 154 | child: GestureDetector( 155 | onPanUpdate: _onPanUpdate, 156 | child: CupertinoPopoverMenu( 157 | focalPoint: StationaryMenuFocalPoint(widget.focalPoint), 158 | padding: const EdgeInsets.all(12.0), 159 | arrowBaseWidth: 21, 160 | arrowLength: 20, 161 | backgroundColor: const Color(0xFF474747), 162 | child: const SizedBox( 163 | width: 254, 164 | height: 159, 165 | child: Center( 166 | child: Text( 167 | 'Popover Content', 168 | style: TextStyle( 169 | color: Colors.white, 170 | fontSize: 20, 171 | ), 172 | ), 173 | ), 174 | ), 175 | ), 176 | ), 177 | ), 178 | ], 179 | ); 180 | } 181 | } 182 | 183 | /// An example of an [IosPopoverMenu] usage. 184 | /// 185 | /// This example includes an [IosPopoverMenu] with fixed content. 186 | class PopoverExample extends StatelessWidget { 187 | const PopoverExample({ 188 | super.key, 189 | required this.focalPoint, 190 | }); 191 | 192 | final Offset focalPoint; 193 | 194 | @override 195 | Widget build(BuildContext context) { 196 | return Center( 197 | child: CupertinoPopoverMenu( 198 | focalPoint: StationaryMenuFocalPoint(focalPoint), 199 | padding: const EdgeInsets.all(12.0), 200 | arrowBaseWidth: 21, 201 | arrowLength: 20, 202 | backgroundColor: const Color(0xFF474747), 203 | child: const SizedBox( 204 | width: 254, 205 | height: 159, 206 | child: Center( 207 | child: Text( 208 | 'Popover Content', 209 | style: TextStyle( 210 | color: Colors.white, 211 | fontSize: 20, 212 | ), 213 | ), 214 | ), 215 | ), 216 | ), 217 | ); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /example/lib/demos/demo_popover_menu_bouncing_ball.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/ball_sandbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | import 'package:overlord/follow_the_leader.dart'; 5 | import 'package:overlord/overlord.dart'; 6 | 7 | /// Displays an [IosPopoverMenu] near a bouncing ball. 8 | class PopoverMenuBouncingBallDemo extends StatefulWidget { 9 | const PopoverMenuBouncingBallDemo({super.key}); 10 | 11 | @override 12 | State createState() => _PopoverMenuBouncingBallDemoState(); 13 | } 14 | 15 | class _PopoverMenuBouncingBallDemoState extends State with SingleTickerProviderStateMixin { 16 | static const double _menuWidth = 100; 17 | static const double _ballRadius = 50.0; 18 | 19 | final GlobalKey _screenBoundsKey = GlobalKey(); 20 | final GlobalKey _leaderKey = GlobalKey(); 21 | final GlobalKey _followerKey = GlobalKey(); 22 | 23 | late final FollowerAligner _aligner; 24 | 25 | /// Current offset of the leader. 26 | /// 27 | /// The offset changes at every tick. 28 | Offset _ballOffset = const Offset(0, 200); 29 | 30 | /// The global offset where the menu's arrow should point. 31 | Offset _globalMenuFocalPoint = Offset.zero; 32 | 33 | @override 34 | void initState() { 35 | super.initState(); 36 | _aligner = CupertinoPopoverMenuAligner(_screenBoundsKey); 37 | } 38 | 39 | /// Calculates the global offset where the menu's arrow should point. 40 | void _updateMenuFocalPoint() { 41 | final screenBoundsBox = _screenBoundsKey.currentContext?.findRenderObject() as RenderBox?; 42 | if (screenBoundsBox == null) { 43 | _globalMenuFocalPoint = Offset.zero; 44 | return; 45 | } 46 | 47 | final focalPointInScreenBounds = _ballOffset + const Offset(_ballRadius, _ballRadius); 48 | final globalLeaderOffset = screenBoundsBox.localToGlobal(focalPointInScreenBounds); 49 | 50 | _globalMenuFocalPoint = globalLeaderOffset; 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | return BouncingBallSandbox( 56 | boundsKey: _screenBoundsKey, 57 | leaderKey: _leaderKey, 58 | followerKey: _followerKey, 59 | followerAligner: _aligner, 60 | follower: _buildMenu(), 61 | initialBallOffset: const Offset(0, 200), 62 | onBallMove: (ballOffset) { 63 | setState(() { 64 | _ballOffset = ballOffset; 65 | _updateMenuFocalPoint(); 66 | }); 67 | }, 68 | ); 69 | } 70 | 71 | Widget _buildMenu() { 72 | return CupertinoPopoverMenu( 73 | focalPoint: StationaryMenuFocalPoint(_globalMenuFocalPoint), 74 | padding: const EdgeInsets.all(12.0), 75 | child: const SizedBox( 76 | width: _menuWidth, 77 | height: 100, 78 | child: Center( 79 | child: Text( 80 | 'Popover Content', 81 | textAlign: TextAlign.center, 82 | style: TextStyle( 83 | color: Colors.white, 84 | fontSize: 20, 85 | ), 86 | ), 87 | ), 88 | ), 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /example/lib/demos/demo_popover_menu_draggable_ball.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/ball_sandbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | import 'package:overlord/follow_the_leader.dart'; 5 | import 'package:overlord/overlord.dart'; 6 | 7 | /// Displays an [IosPopoverMenu] near a draggable ball. 8 | class PopoverMenuDraggableBallDemo extends StatefulWidget { 9 | const PopoverMenuDraggableBallDemo({super.key}); 10 | 11 | @override 12 | State createState() => _PopoverMenuDraggableBallDemoState(); 13 | } 14 | 15 | class _PopoverMenuDraggableBallDemoState extends State { 16 | static const double _menuWidth = 100; 17 | static const double _draggableBallRadius = 50.0; 18 | 19 | final GlobalKey _screenBoundsKey = GlobalKey(); 20 | final GlobalKey _leaderKey = GlobalKey(); 21 | final GlobalKey _followerKey = GlobalKey(); 22 | 23 | late final FollowerAligner _aligner; 24 | 25 | /// The (x,y) position of the draggable object, which is also our `Leader`. 26 | Offset _draggableOffset = const Offset(300, 250); 27 | 28 | /// The global offset where the menu's arrow should point. 29 | Offset _globalMenuFocalPoint = Offset.zero; 30 | 31 | @override 32 | void initState() { 33 | super.initState(); 34 | 35 | _aligner = CupertinoPopoverMenuAligner(_screenBoundsKey); 36 | 37 | // After the first frame, calculate the menu focal point. 38 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 39 | setState(() { 40 | _updateMenuFocalPoint(); 41 | }); 42 | }); 43 | } 44 | 45 | void _onBallMove(Offset offset) { 46 | setState(() { 47 | // Update _draggableOffset before updating the menu focal point 48 | _draggableOffset = offset; 49 | _updateMenuFocalPoint(); 50 | }); 51 | } 52 | 53 | /// Calculates the global offset where the menu's arrow should point. 54 | void _updateMenuFocalPoint() { 55 | final screenBoundsBox = _screenBoundsKey.currentContext?.findRenderObject() as RenderBox?; 56 | if (screenBoundsBox == null) { 57 | _globalMenuFocalPoint = Offset.zero; 58 | return; 59 | } 60 | 61 | final focalPointInScreenBounds = _draggableOffset + const Offset(_draggableBallRadius, _draggableBallRadius); 62 | final globalLeaderOffset = screenBoundsBox.localToGlobal(focalPointInScreenBounds); 63 | 64 | _globalMenuFocalPoint = globalLeaderOffset; 65 | } 66 | 67 | @override 68 | Widget build(BuildContext context) { 69 | return Stack( 70 | children: [ 71 | DraggableBallSandbox( 72 | boundsKey: _screenBoundsKey, 73 | leaderKey: _leaderKey, 74 | followerKey: _followerKey, 75 | followerAligner: _aligner, 76 | follower: _buildMenu(), 77 | initialBallOffset: _draggableOffset, 78 | onBallMove: _onBallMove, 79 | ), 80 | _buildDebugFocalPoint(), 81 | ], 82 | ); 83 | } 84 | 85 | Widget _buildMenu() { 86 | return CupertinoPopoverMenu( 87 | focalPoint: StationaryMenuFocalPoint(_globalMenuFocalPoint), 88 | padding: const EdgeInsets.all(12.0), 89 | child: const SizedBox( 90 | width: _menuWidth, 91 | height: 100, 92 | child: Center( 93 | child: Text( 94 | 'Popover Content', 95 | textAlign: TextAlign.center, 96 | style: TextStyle( 97 | color: Colors.white, 98 | fontSize: 20, 99 | ), 100 | ), 101 | ), 102 | ), 103 | ); 104 | } 105 | 106 | Widget _buildDebugFocalPoint() { 107 | return Positioned( 108 | left: _globalMenuFocalPoint.dx, 109 | top: _globalMenuFocalPoint.dy, 110 | child: FractionalTranslation( 111 | translation: const Offset(-0.5, -0.5), 112 | child: Container( 113 | width: 10, 114 | height: 10, 115 | decoration: const BoxDecoration( 116 | shape: BoxShape.circle, 117 | color: Colors.red, 118 | ), 119 | ), 120 | ), 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | /// Demo which shows the capabilities of the [IosToolbar]. 7 | /// 8 | /// This demo includes examples of the toolbar pointing up and down, 9 | /// menus with many pages, including an auto-paginated and a manually paginated menu. 10 | /// 11 | /// It also includes a draggable example, where the user can drag the toolbar around the screen 12 | /// and the toolbar updates the arrow direction to always point to the focal point. 13 | class ToolbarDemo extends StatefulWidget { 14 | const ToolbarDemo({Key? key}) : super(key: key); 15 | 16 | @override 17 | State createState() => _ToolbarDemoState(); 18 | } 19 | 20 | class _ToolbarDemoState extends State { 21 | late final ScrollController _scrollController; 22 | 23 | @override 24 | void initState() { 25 | super.initState(); 26 | _scrollController = ScrollController()..addListener(_onScrollChange); 27 | } 28 | 29 | @override 30 | void dispose() { 31 | _scrollController.dispose(); 32 | super.dispose(); 33 | } 34 | 35 | void _onScrollChange() { 36 | setState(() { 37 | // We rebuild the tree so that each demo rebuilds, and gets an opportunity 38 | // to locate its new global offset focal point, due to the scroll movement. 39 | // 40 | // Rebuilding on every scroll frame is very inefficient and shouldn't be 41 | // done in general. If you want a menu to follow a moving focal point, consider 42 | // using the follow_the_leader package to follow a moving widget. 43 | }); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return SizedBox.expand( 49 | child: SingleChildScrollView( 50 | controller: _scrollController, 51 | child: _buildDemoGrid(), 52 | ), 53 | ); 54 | } 55 | 56 | Widget _buildDemoGrid() { 57 | final demoRows = []; 58 | for (int i = 0; i < _demoItems.length; i += 2) { 59 | demoRows.add( 60 | Row( 61 | children: [ 62 | Expanded( 63 | child: _buildDemo(_demoItems[i].builder), 64 | ), 65 | Expanded( 66 | child: (i + 1 < _demoItems.length) // 67 | ? _buildDemo(_demoItems[i + 1].builder) // 68 | : const SizedBox(), 69 | ), 70 | ], 71 | ), 72 | ); 73 | } 74 | 75 | return Column( 76 | children: demoRows, 77 | ); 78 | } 79 | 80 | Widget _buildDemo(_DemoBuilder demoBuilder) { 81 | return AspectRatio( 82 | aspectRatio: 1.0, 83 | child: Container( 84 | width: double.infinity, 85 | height: double.infinity, 86 | decoration: BoxDecoration( 87 | border: Border.all(color: Colors.white.withOpacity(0.1), width: 1), 88 | ), 89 | child: LayoutBuilder(builder: (context, constraints) { 90 | return demoBuilder(context, constraints.loosen()); 91 | }), 92 | ), 93 | ); 94 | } 95 | } 96 | 97 | typedef _DemoBuilder = Widget Function(BuildContext, [BoxConstraints boundConstraints]); 98 | 99 | final _demoItems = [ 100 | _ToolbarDemoItem( 101 | label: 'Pointing Up', 102 | builder: (context, [BoxConstraints? constraints]) { 103 | Offset focalPoint = const Offset(600, 0); 104 | if (constraints != null) { 105 | final size = constraints.biggest; 106 | focalPoint = Alignment.topCenter.alongSize(size); 107 | } 108 | 109 | return ToolbarExample( 110 | demoTitle: "Pointing Up", 111 | focalPoint: focalPoint, 112 | constraints: constraints, 113 | children: _shortListOfToolbarItems, 114 | ); 115 | }, 116 | ), 117 | _ToolbarDemoItem( 118 | label: 'Pointing Down', 119 | builder: (context, [BoxConstraints? constraints]) { 120 | Offset focalPoint = const Offset(600, 1000); 121 | if (constraints != null) { 122 | final size = constraints.biggest; 123 | focalPoint = Alignment.bottomCenter.alongSize(size); 124 | } 125 | 126 | return ToolbarExample( 127 | demoTitle: "Pointing Down", 128 | focalPoint: focalPoint, 129 | constraints: constraints, 130 | children: _shortListOfToolbarItems, 131 | ); 132 | }, 133 | ), 134 | _ToolbarDemoItem( 135 | label: 'Thin', 136 | builder: (context, [BoxConstraints? constraints]) { 137 | Offset focalPoint = const Offset(600, 1000); 138 | if (constraints != null) { 139 | final size = constraints.biggest; 140 | focalPoint = Alignment.bottomCenter.alongSize(size); 141 | } 142 | 143 | return ToolbarExample( 144 | demoTitle: "Thin", 145 | focalPoint: focalPoint, 146 | constraints: constraints, 147 | toolbarHeight: 24, 148 | children: _shortListOfToolbarItems, 149 | ); 150 | }, 151 | ), 152 | _ToolbarDemoItem( 153 | label: 'Thick', 154 | builder: (context, [BoxConstraints? constraints]) { 155 | Offset focalPoint = const Offset(600, 1000); 156 | if (constraints != null) { 157 | final size = constraints.biggest; 158 | focalPoint = Alignment.bottomCenter.alongSize(size); 159 | } 160 | 161 | return ToolbarExample( 162 | demoTitle: "Thick", 163 | focalPoint: focalPoint, 164 | constraints: constraints, 165 | toolbarHeight: 72, 166 | children: _shortListOfToolbarItems, 167 | ); 168 | }, 169 | ), 170 | _ToolbarDemoItem( 171 | label: 'Auto Paginated', 172 | builder: (context, [BoxConstraints? constraints]) { 173 | Offset focalPoint = const Offset(600, 1000); 174 | if (constraints != null) { 175 | final size = constraints.biggest; 176 | focalPoint = Alignment.bottomCenter.alongSize(size); 177 | } 178 | 179 | return ToolbarExample( 180 | demoTitle: "Auto Paginated", 181 | focalPoint: focalPoint, 182 | constraints: constraints, 183 | children: _longListOfToolbarItems, 184 | ); 185 | }, 186 | ), 187 | _ToolbarDemoItem( 188 | label: 'Manually Paginated', 189 | builder: (context, [BoxConstraints? constraints]) { 190 | Offset focalPoint = const Offset(600, 1000); 191 | if (constraints != null) { 192 | final size = constraints.biggest; 193 | focalPoint = Alignment.bottomCenter.alongSize(size); 194 | } 195 | 196 | return ToolbarExample( 197 | demoTitle: "Manually Paginated", 198 | focalPoint: focalPoint, 199 | constraints: constraints, 200 | pages: _pagedToolbarItems, 201 | ); 202 | }, 203 | ), 204 | _ToolbarDemoItem( 205 | label: 'Draggable', 206 | builder: (context, [BoxConstraints? constraints]) { 207 | Offset focalPoint = const Offset(600, 1000); 208 | if (constraints != null) { 209 | final size = constraints.biggest; 210 | focalPoint = Alignment.center.alongSize(size); 211 | } 212 | 213 | return _DraggableDemo( 214 | focalPoint: focalPoint, 215 | children: _shortListOfToolbarItems, 216 | ); 217 | }, 218 | ), 219 | ]; 220 | 221 | final _shortListOfToolbarItems = [ 222 | CupertinoPopoverToolbarMenuItem( 223 | label: 'Style', 224 | onPressed: () { 225 | print("Pressed 'Style'"); 226 | }, 227 | ), 228 | CupertinoPopoverToolbarMenuItem( 229 | label: 'Duplicate', 230 | onPressed: () { 231 | print("Pressed 'Duplicate'"); 232 | }, 233 | ), 234 | CupertinoPopoverToolbarMenuItem( 235 | label: 'Cut', 236 | onPressed: () { 237 | print("Pressed 'Cut'"); 238 | }, 239 | ), 240 | CupertinoPopoverToolbarMenuItem( 241 | label: 'Copy', 242 | onPressed: () { 243 | print("Pressed 'Copy'"); 244 | }, 245 | ), 246 | CupertinoPopoverToolbarMenuItem( 247 | label: 'Paste', 248 | onPressed: () { 249 | print("Pressed 'Paste'"); 250 | }, 251 | ), 252 | ]; 253 | 254 | final _longListOfToolbarItems = [ 255 | CupertinoPopoverToolbarMenuItem( 256 | label: 'Style', 257 | onPressed: () { 258 | print("Pressed 'Style'"); 259 | }, 260 | ), 261 | CupertinoPopoverToolbarMenuItem( 262 | label: 'Duplicate', 263 | onPressed: () { 264 | print("Pressed 'Duplicate'"); 265 | }, 266 | ), 267 | CupertinoPopoverToolbarMenuItem( 268 | label: 'Cut', 269 | onPressed: () { 270 | print("Pressed 'Cut'"); 271 | }, 272 | ), 273 | CupertinoPopoverToolbarMenuItem( 274 | label: 'Copy', 275 | onPressed: () { 276 | print("Pressed 'Copy'"); 277 | }, 278 | ), 279 | CupertinoPopoverToolbarMenuItem( 280 | label: 'Paste', 281 | onPressed: () { 282 | print("Pressed 'Paste'"); 283 | }, 284 | ), 285 | CupertinoPopoverToolbarMenuItem( 286 | label: 'Delete', 287 | onPressed: () { 288 | print("Pressed 'Delete'"); 289 | }, 290 | ), 291 | CupertinoPopoverToolbarMenuItem( 292 | label: 'Long Thing 1', 293 | onPressed: () { 294 | print("Pressed 'Long Thing 1'"); 295 | }, 296 | ), 297 | CupertinoPopoverToolbarMenuItem( 298 | label: 'Long Thing 2', 299 | onPressed: () { 300 | print("Pressed 'Long Thing 2'"); 301 | }, 302 | ), 303 | CupertinoPopoverToolbarMenuItem( 304 | label: 'Long Thing 3', 305 | onPressed: () { 306 | print("Pressed 'Long Thing 3'"); 307 | }, 308 | ), 309 | CupertinoPopoverToolbarMenuItem( 310 | label: 'Long Thing 4', 311 | onPressed: () { 312 | print("Pressed 'Long Thing 4'"); 313 | }, 314 | ), 315 | CupertinoPopoverToolbarMenuItem( 316 | label: 'Long Thing 5', 317 | onPressed: () { 318 | print("Pressed 'Long Thing 5'"); 319 | }, 320 | ), 321 | ]; 322 | 323 | final _pagedToolbarItems = [ 324 | MenuPage( 325 | items: [ 326 | CupertinoPopoverToolbarMenuItem( 327 | label: 'Style', 328 | onPressed: () { 329 | print("Pressed 'Style'"); 330 | }, 331 | ), 332 | CupertinoPopoverToolbarMenuItem( 333 | label: 'Duplicate', 334 | onPressed: () { 335 | print("Pressed 'Duplicate'"); 336 | }, 337 | ), 338 | ], 339 | ), 340 | MenuPage( 341 | items: [ 342 | CupertinoPopoverToolbarMenuItem( 343 | label: 'Cut', 344 | onPressed: () { 345 | print("Pressed 'Cut'"); 346 | }, 347 | ), 348 | CupertinoPopoverToolbarMenuItem( 349 | label: 'Copy', 350 | onPressed: () { 351 | print("Pressed 'Copy'"); 352 | }, 353 | ), 354 | CupertinoPopoverToolbarMenuItem( 355 | label: 'Paste', 356 | onPressed: () { 357 | print("Pressed 'Paste'"); 358 | }, 359 | ), 360 | CupertinoPopoverToolbarMenuItem( 361 | label: 'Delete', 362 | onPressed: () { 363 | print("Pressed 'Delete'"); 364 | }, 365 | ), 366 | ], 367 | ), 368 | MenuPage( 369 | items: [ 370 | CupertinoPopoverToolbarMenuItem( 371 | label: 'Page 3 Copy', 372 | onPressed: () { 373 | print("Pressed 'Page 3 Copy'"); 374 | }, 375 | ), 376 | CupertinoPopoverToolbarMenuItem( 377 | label: 'Page 3 Paste', 378 | onPressed: () { 379 | print("Pressed 'Page 3 Paste'"); 380 | }, 381 | ), 382 | CupertinoPopoverToolbarMenuItem( 383 | label: 'Page 3 Delete', 384 | onPressed: () { 385 | print("Pressed 'Page 3 Delete'"); 386 | }, 387 | ), 388 | ], 389 | ), 390 | ]; 391 | 392 | class _ToolbarDemoItem { 393 | _ToolbarDemoItem({ 394 | required this.label, 395 | required this.builder, 396 | }); 397 | 398 | final String label; 399 | final _DemoBuilder builder; 400 | } 401 | 402 | /// An example of an [IosToolbar] usage. 403 | /// 404 | /// When [constraints] are provided, the toolbar is displayed inside a [ConstrainedBox]. 405 | /// 406 | /// Use [pages] to manually configure the menu pages. 407 | /// 408 | /// Use [children] to let the toolbar compute the pages based on the available width. 409 | class ToolbarExample extends StatefulWidget { 410 | const ToolbarExample({ 411 | super.key, 412 | required this.demoTitle, 413 | required this.focalPoint, 414 | this.constraints, 415 | this.pages, 416 | this.toolbarHeight, 417 | this.children, 418 | }) : assert(children != null || pages != null, 'You should provide either children or pages'), 419 | assert(children == null || pages == null, "You can't provide both children and pages"); 420 | 421 | final String demoTitle; 422 | final BoxConstraints? constraints; 423 | final List? pages; 424 | final Offset focalPoint; 425 | final double? toolbarHeight; 426 | final List? children; 427 | 428 | @override 429 | State createState() => _ToolbarExampleState(); 430 | } 431 | 432 | class _ToolbarExampleState extends State { 433 | @override 434 | Widget build(BuildContext context) { 435 | // Re-calculate focal point on every frame, because the toolbar expects a 436 | // global value, and these demos can scroll up and down, so it changes. 437 | Offset globalFocalPoint = widget.focalPoint; 438 | final myBox = context.findRenderObject() as RenderBox?; 439 | if (myBox != null) { 440 | globalFocalPoint = myBox.localToGlobal(Offset.zero) + widget.focalPoint; 441 | } else { 442 | // Schedule another frame so that we start with the correct global 443 | // focal point. 444 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 445 | setState(() {}); 446 | }); 447 | } 448 | 449 | final toolbar = widget.children != null 450 | ? CupertinoPopoverToolbar( 451 | focalPoint: StationaryMenuFocalPoint(globalFocalPoint), 452 | height: widget.toolbarHeight, 453 | children: widget.children!, 454 | ) 455 | : CupertinoPopoverToolbar.paginated( 456 | focalPoint: StationaryMenuFocalPoint(globalFocalPoint), 457 | height: widget.toolbarHeight, 458 | pages: widget.pages, 459 | ); 460 | 461 | final constrainedToolbar = widget.constraints != null 462 | ? ConstrainedBox( 463 | constraints: widget.constraints!, 464 | child: toolbar, 465 | ) 466 | : toolbar; 467 | 468 | return Stack( 469 | children: [ 470 | Align( 471 | alignment: const Alignment(0.0, 0.9), 472 | child: Container( 473 | height: 24, 474 | padding: const EdgeInsets.symmetric(horizontal: 16), 475 | decoration: BoxDecoration( 476 | color: Colors.red, 477 | borderRadius: BorderRadius.circular(16), 478 | ), 479 | child: IntrinsicWidth( 480 | child: Center( 481 | child: Text( 482 | widget.demoTitle, 483 | style: const TextStyle(color: Colors.white, fontSize: 12), 484 | ), 485 | ), 486 | ), 487 | ), 488 | ), 489 | Center( 490 | child: constrainedToolbar, 491 | ), 492 | ], 493 | ); 494 | } 495 | } 496 | 497 | class _DraggableDemo extends StatefulWidget { 498 | const _DraggableDemo({ 499 | // ignore: unused_element 500 | super.key, 501 | required this.focalPoint, 502 | required this.children, 503 | }); 504 | 505 | final Offset focalPoint; 506 | final List children; 507 | 508 | @override 509 | State<_DraggableDemo> createState() => _DraggableDemoState(); 510 | } 511 | 512 | class _DraggableDemoState extends State<_DraggableDemo> { 513 | Offset _offset = const Offset(50, 50); 514 | 515 | void _onPanUpdate(DragUpdateDetails details) { 516 | setState(() { 517 | _offset += details.delta; 518 | }); 519 | } 520 | 521 | @override 522 | Widget build(BuildContext context) { 523 | // Re-calculate focal point on every frame, because the toolbar expects a 524 | // global value, and these demos can scroll up and down, so it changes. 525 | Offset globalFocalPoint = widget.focalPoint; 526 | final myBox = context.findRenderObject() as RenderBox?; 527 | if (myBox != null) { 528 | globalFocalPoint = myBox.localToGlobal(Offset.zero) + widget.focalPoint; 529 | } else { 530 | // Schedule another frame so that we start with the correct global 531 | // focal point. 532 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 533 | setState(() {}); 534 | }); 535 | } 536 | 537 | return Stack( 538 | children: [ 539 | Positioned( 540 | left: widget.focalPoint.dx, 541 | top: widget.focalPoint.dy, 542 | child: Container( 543 | color: Colors.red, 544 | height: 10, 545 | width: 10, 546 | ), 547 | ), 548 | Positioned( 549 | left: _offset.dx, 550 | top: _offset.dy, 551 | child: GestureDetector( 552 | onPanUpdate: _onPanUpdate, 553 | child: CupertinoPopoverToolbar( 554 | focalPoint: StationaryMenuFocalPoint(globalFocalPoint), 555 | children: widget.children, 556 | ), 557 | ), 558 | ), 559 | ], 560 | ); 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar_bouncing_ball.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/ball_sandbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | import 'package:overlord/follow_the_leader.dart'; 5 | import 'package:overlord/overlord.dart'; 6 | 7 | /// Displays an [IosToolbar] near a bouncing ball. 8 | class ToolbarBouncingBallDemo extends StatefulWidget { 9 | const ToolbarBouncingBallDemo({super.key}); 10 | 11 | @override 12 | State createState() => _ToolbarBouncingBallDemoState(); 13 | } 14 | 15 | class _ToolbarBouncingBallDemoState extends State with SingleTickerProviderStateMixin { 16 | static const double _ballRadius = 50.0; 17 | 18 | final GlobalKey _screenBoundsKey = GlobalKey(); 19 | final GlobalKey _leaderKey = GlobalKey(); 20 | final GlobalKey _followerKey = GlobalKey(); 21 | 22 | late final FollowerAligner _aligner; 23 | 24 | /// Current offset of the leader. 25 | /// 26 | /// The offset changes at every tick. 27 | Offset _ballOffset = const Offset(300, 200); 28 | 29 | /// The global offset where the menu's arrow should point. 30 | Offset _globalMenuFocalPoint = Offset.zero; 31 | 32 | @override 33 | void initState() { 34 | super.initState(); 35 | _aligner = CupertinoPopoverToolbarAligner(_screenBoundsKey); 36 | } 37 | 38 | /// Calculates the global offset where the menu's arrow should point. 39 | void _updateMenuFocalPoint() { 40 | final screenBoundsBox = _screenBoundsKey.currentContext?.findRenderObject() as RenderBox?; 41 | if (screenBoundsBox == null) { 42 | _globalMenuFocalPoint = Offset.zero; 43 | return; 44 | } 45 | 46 | final focalPointInScreenBounds = _ballOffset + const Offset(_ballRadius, _ballRadius); 47 | final globalLeaderOffset = screenBoundsBox.localToGlobal(focalPointInScreenBounds); 48 | 49 | _globalMenuFocalPoint = globalLeaderOffset; 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | return BouncingBallSandbox( 55 | boundsKey: _screenBoundsKey, 56 | leaderKey: _leaderKey, 57 | followerKey: _followerKey, 58 | followerAligner: _aligner, 59 | follower: _buildMenu(), 60 | initialBallOffset: _ballOffset, 61 | onBallMove: (ballOffset) { 62 | setState(() { 63 | _ballOffset = ballOffset; 64 | _updateMenuFocalPoint(); 65 | }); 66 | }, 67 | ); 68 | } 69 | 70 | Widget _buildMenu() { 71 | return CupertinoPopoverToolbar( 72 | focalPoint: StationaryMenuFocalPoint(_globalMenuFocalPoint), 73 | children: const [ 74 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 75 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 76 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 77 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 78 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 79 | ], 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar_draggable_ball.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/ball_sandbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | import 'package:overlord/follow_the_leader.dart'; 5 | import 'package:overlord/overlord.dart'; 6 | 7 | /// Displays an [IosToolbar] near a draggable ball. 8 | class ToolbarDraggableBallDemo extends StatefulWidget { 9 | const ToolbarDraggableBallDemo({super.key}); 10 | 11 | @override 12 | State createState() => _ToolbarDraggableBallDemoState(); 13 | } 14 | 15 | class _ToolbarDraggableBallDemoState extends State { 16 | static const double _draggableBallRadius = 50.0; 17 | 18 | final GlobalKey _screenBoundsKey = GlobalKey(); 19 | final GlobalKey _leaderKey = GlobalKey(); 20 | final GlobalKey _followerKey = GlobalKey(); 21 | 22 | late final FollowerAligner _aligner; 23 | 24 | /// The (x,y) position of the draggable object, which is also our `Leader`. 25 | Offset _ballOffset = const Offset(300, 200); 26 | 27 | /// The global offset where the menu's arrow should point. 28 | Offset _globalMenuFocalPoint = Offset.zero; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | 34 | _aligner = CupertinoPopoverToolbarAligner(_screenBoundsKey); 35 | 36 | // After the first frame, calculate the menu focal point. 37 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 38 | setState(() { 39 | _updateMenuFocalPoint(); 40 | }); 41 | }); 42 | } 43 | 44 | void _onBallMove(Offset offset) { 45 | setState(() { 46 | // Update _draggableOffset before updating the menu focal point 47 | _ballOffset = offset; 48 | _updateMenuFocalPoint(); 49 | }); 50 | } 51 | 52 | /// Calculates the global offset where the menu's arrow should point. 53 | void _updateMenuFocalPoint() { 54 | final screenBoundsBox = _screenBoundsKey.currentContext?.findRenderObject() as RenderBox?; 55 | if (screenBoundsBox == null) { 56 | _globalMenuFocalPoint = Offset.zero; 57 | return; 58 | } 59 | 60 | final focalPointInScreenBounds = _ballOffset + const Offset(_draggableBallRadius, _draggableBallRadius); 61 | final globalLeaderOffset = screenBoundsBox.localToGlobal(focalPointInScreenBounds); 62 | 63 | _globalMenuFocalPoint = globalLeaderOffset; 64 | } 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | return Stack( 69 | children: [ 70 | DraggableBallSandbox( 71 | boundsKey: _screenBoundsKey, 72 | leaderKey: _leaderKey, 73 | followerKey: _followerKey, 74 | followerAligner: _aligner, 75 | follower: _buildMenu(), 76 | initialBallOffset: _ballOffset, 77 | onBallMove: _onBallMove, 78 | ), 79 | _buildDebugFocalPoint(), 80 | ], 81 | ); 82 | } 83 | 84 | Widget _buildMenu() { 85 | return CupertinoPopoverToolbar( 86 | focalPoint: StationaryMenuFocalPoint(_globalMenuFocalPoint), 87 | children: const [ 88 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 89 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 90 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 91 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 92 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 93 | ], 94 | ); 95 | } 96 | 97 | Widget _buildDebugFocalPoint() { 98 | return Positioned( 99 | left: _globalMenuFocalPoint.dx, 100 | top: _globalMenuFocalPoint.dy, 101 | child: FractionalTranslation( 102 | translation: const Offset(-0.5, -0.5), 103 | child: Container( 104 | width: 10, 105 | height: 10, 106 | decoration: const BoxDecoration( 107 | shape: BoxShape.circle, 108 | color: Colors.red, 109 | ), 110 | ), 111 | ), 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar_moving_focal_point.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'dart:ui'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:follow_the_leader/follow_the_leader.dart'; 6 | import 'package:overlord/follow_the_leader.dart'; 7 | import 'package:overlord/overlord.dart'; 8 | 9 | /// Demo that simulates the presentation of an iOS toolbar when a user 10 | /// expands and contracts a text selection. 11 | /// 12 | /// This demo doesn't include any real text selection manipulation. Instead, 13 | /// this demo includes a rectangle that grows and shrinks when the user 14 | /// presses a button. The rectangle that grows and shrinks represents the 15 | /// selection box for some text, where the user drags a handle to expand 16 | /// or contract it. The toolbar disappears while the rectangle changes width, 17 | /// which simulates typical mobile behavior in which a magnifier replaces the 18 | /// toolbar during text expansion. 19 | /// 20 | /// This demo was introduced because the iOS toolbar had a mis-aligned arrow 21 | /// every time the user dragged a new selection. The arrow would correct its 22 | /// orientation after forcing a re-paint. As of `follow_the_leader` `v0.0.4+5`, 23 | /// this demo should always display a correctly oriented toolbar arrow after 24 | /// changing the rectangle's size. 25 | class ToolbarExpandingFocalPointDemo extends StatefulWidget { 26 | const ToolbarExpandingFocalPointDemo({super.key}); 27 | 28 | @override 29 | State createState() => _ToolbarExpandingFocalPointDemoState(); 30 | } 31 | 32 | class _ToolbarExpandingFocalPointDemoState extends State 33 | with SingleTickerProviderStateMixin { 34 | final _leaderLink = LeaderLink(); 35 | final _viewportKey = GlobalKey(); 36 | 37 | final _baseContentWidth = 10.0; 38 | final _expansionExtent = ValueNotifier(0); 39 | 40 | late final OverlayEntry _toolbarEntry; 41 | 42 | late final AnimationController _animationController; 43 | double _startExtent = 10; 44 | double _endExtent = 10; 45 | 46 | bool _showToolbar = true; 47 | 48 | @override 49 | void initState() { 50 | super.initState(); 51 | 52 | _animationController = AnimationController(vsync: this, duration: const Duration(seconds: 1)) 53 | ..addStatusListener((status) { 54 | switch (status) { 55 | case AnimationStatus.completed: 56 | _startExtent = _endExtent; 57 | 58 | _showToolbar = true; 59 | _toolbarEntry.markNeedsBuild(); 60 | break; 61 | case AnimationStatus.dismissed: 62 | case AnimationStatus.forward: 63 | case AnimationStatus.reverse: 64 | // no-op 65 | break; 66 | } 67 | }) 68 | ..addListener(() { 69 | _expansionExtent.value = lerpDouble(_startExtent, _endExtent, _animationController.value)!; 70 | }); 71 | } 72 | 73 | @override 74 | void didChangeDependencies() { 75 | super.didChangeDependencies(); 76 | 77 | _toolbarEntry = OverlayEntry(builder: (_) { 78 | return _buildToolbarOverlay(); 79 | }); 80 | 81 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 82 | Overlay.of(context).insert(_toolbarEntry); 83 | }); 84 | } 85 | 86 | @override 87 | void dispose() { 88 | _toolbarEntry.remove(); 89 | 90 | _animationController.dispose(); 91 | 92 | super.dispose(); 93 | } 94 | 95 | @override 96 | Widget build(BuildContext context) { 97 | return KeyedSubtree( 98 | key: _viewportKey, 99 | child: Center( 100 | child: Column( 101 | children: [ 102 | const Spacer(), 103 | ValueListenableBuilder( 104 | valueListenable: _expansionExtent, 105 | builder: (context, expansionExtent, _) { 106 | return Container( 107 | height: 12, 108 | width: _baseContentWidth + (2 * expansionExtent) + 2, // +2 for border 109 | decoration: BoxDecoration( 110 | border: Border.all(color: Colors.white.withOpacity(0.1)), 111 | ), 112 | child: Align( 113 | alignment: Alignment.centerLeft, 114 | child: Leader( 115 | link: _leaderLink, 116 | child: Container( 117 | width: _baseContentWidth + expansionExtent, 118 | height: 10, 119 | color: Colors.red, 120 | ), 121 | ), 122 | ), 123 | ); 124 | }, 125 | ), 126 | const SizedBox(height: 96), 127 | TextButton( 128 | onPressed: () { 129 | _endExtent = Random().nextDouble() * 200; 130 | _animationController.forward(from: 0); 131 | 132 | _showToolbar = false; 133 | _toolbarEntry.markNeedsBuild(); 134 | }, 135 | child: const Text("Change Size"), 136 | ), 137 | const Spacer(), 138 | ], 139 | ), 140 | ), 141 | ); 142 | } 143 | 144 | Widget _buildToolbarOverlay() { 145 | if (!_showToolbar) { 146 | return const SizedBox(); 147 | } 148 | 149 | return FollowerFadeOutBeyondBoundary( 150 | link: _leaderLink, 151 | boundary: WidgetFollowerBoundary( 152 | boundaryKey: _viewportKey, 153 | devicePixelRatio: MediaQuery.devicePixelRatioOf(context), 154 | ), 155 | child: Follower.withAligner( 156 | link: _leaderLink, 157 | aligner: CupertinoPopoverToolbarAligner(_viewportKey), 158 | child: CupertinoPopoverToolbar( 159 | focalPoint: LeaderMenuFocalPoint(link: _leaderLink), 160 | // height: 54, 161 | children: [ 162 | CupertinoPopoverToolbarMenuItem( 163 | label: 'Cut', 164 | onPressed: () {}, 165 | ), 166 | CupertinoPopoverToolbarMenuItem( 167 | label: 'Copy', 168 | onPressed: () {}, 169 | ), 170 | CupertinoPopoverToolbarMenuItem( 171 | label: 'Paste', 172 | onPressed: () {}, 173 | ), 174 | ], 175 | ), 176 | ), 177 | ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar_wide_draggable_ball.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/infrastructure/ball_sandbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | import 'package:overlord/follow_the_leader.dart'; 5 | import 'package:overlord/overlord.dart'; 6 | 7 | /// Displays a very wide [IosToolbar] near a draggable ball. 8 | class WideToolbarDraggableBallDemo extends StatefulWidget { 9 | const WideToolbarDraggableBallDemo({super.key}); 10 | 11 | @override 12 | State createState() => _WideToolbarDraggableBallDemoState(); 13 | } 14 | 15 | class _WideToolbarDraggableBallDemoState extends State { 16 | static const double _draggableBallRadius = 50.0; 17 | 18 | final GlobalKey _screenBoundsKey = GlobalKey(); 19 | final GlobalKey _leaderKey = GlobalKey(); 20 | final GlobalKey _followerKey = GlobalKey(); 21 | 22 | late final FollowerAligner _aligner; 23 | 24 | /// The (x,y) position of the draggable object, which is also our `Leader`. 25 | Offset _ballOffset = const Offset(300, 200); 26 | 27 | /// The global offset where the menu's arrow should point. 28 | Offset _globalMenuFocalPoint = Offset.zero; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | 34 | _aligner = CupertinoPopoverToolbarAligner(_screenBoundsKey); 35 | 36 | // After the first frame, calculate the menu focal point. 37 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 38 | setState(() { 39 | _updateMenuFocalPoint(); 40 | }); 41 | }); 42 | } 43 | 44 | void _onBallMove(Offset offset) { 45 | setState(() { 46 | // Update _draggableOffset before updating the menu focal point 47 | _ballOffset = offset; 48 | _updateMenuFocalPoint(); 49 | }); 50 | } 51 | 52 | /// Calculates the global offset where the menu's arrow should point. 53 | void _updateMenuFocalPoint() { 54 | final screenBoundsBox = _screenBoundsKey.currentContext?.findRenderObject() as RenderBox?; 55 | if (screenBoundsBox == null) { 56 | _globalMenuFocalPoint = Offset.zero; 57 | return; 58 | } 59 | 60 | final focalPointInScreenBounds = _ballOffset + const Offset(_draggableBallRadius, _draggableBallRadius); 61 | final globalLeaderOffset = screenBoundsBox.localToGlobal(focalPointInScreenBounds); 62 | 63 | _globalMenuFocalPoint = globalLeaderOffset; 64 | } 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | return Stack( 69 | children: [ 70 | DraggableBallSandbox( 71 | boundsKey: _screenBoundsKey, 72 | leaderKey: _leaderKey, 73 | followerKey: _followerKey, 74 | followerAligner: _aligner, 75 | follower: _buildMenu(), 76 | initialBallOffset: _ballOffset, 77 | onBallMove: _onBallMove, 78 | ), 79 | _buildDebugFocalPoint(), 80 | ], 81 | ); 82 | } 83 | 84 | Widget _buildMenu() { 85 | return CupertinoPopoverToolbar( 86 | focalPoint: StationaryMenuFocalPoint(_globalMenuFocalPoint), 87 | children: const [ 88 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 89 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 90 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 91 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 92 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 93 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 94 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 95 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 96 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 97 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 98 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 99 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 100 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 101 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 102 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 103 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 104 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 105 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 106 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 107 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 108 | CupertinoPopoverToolbarMenuItem(label: 'Style'), 109 | CupertinoPopoverToolbarMenuItem(label: 'Duplicate'), 110 | CupertinoPopoverToolbarMenuItem(label: 'Cut'), 111 | CupertinoPopoverToolbarMenuItem(label: 'Copy'), 112 | CupertinoPopoverToolbarMenuItem(label: 'Paste'), 113 | ], 114 | ); 115 | } 116 | 117 | Widget _buildDebugFocalPoint() { 118 | return Positioned( 119 | left: _globalMenuFocalPoint.dx, 120 | top: _globalMenuFocalPoint.dy, 121 | child: FractionalTranslation( 122 | translation: const Offset(-0.5, -0.5), 123 | child: Container( 124 | width: 10, 125 | height: 10, 126 | decoration: const BoxDecoration( 127 | shape: BoxShape.circle, 128 | color: Colors.red, 129 | ), 130 | ), 131 | ), 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /example/lib/demos/demo_toolbar_with_scrolling_focal_point.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | import 'package:overlord/follow_the_leader.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | class ToolbarWithScrollingFocalPointDemo extends StatefulWidget { 7 | const ToolbarWithScrollingFocalPointDemo({super.key}); 8 | 9 | @override 10 | State createState() => _ToolbarWithScrollingFocalPointDemoState(); 11 | } 12 | 13 | class _ToolbarWithScrollingFocalPointDemoState extends State { 14 | final _leaderLink = LeaderLink(); 15 | final _viewportKey = GlobalKey(); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return BuildInOrder( 20 | children: [ 21 | Center( 22 | child: Column( 23 | children: [ 24 | const Spacer(), 25 | ConstrainedBox( 26 | constraints: const BoxConstraints(maxWidth: 300, maxHeight: 500), 27 | child: ColoredBox( 28 | key: _viewportKey, 29 | color: Colors.black.withOpacity(0.2), 30 | child: SingleChildScrollView( 31 | child: Container( 32 | width: double.infinity, 33 | height: 1000, 34 | color: Colors.black.withOpacity(0.2), 35 | child: Stack( 36 | children: [ 37 | Positioned( 38 | left: 0, 39 | right: 0, 40 | top: 250, 41 | child: Align( 42 | alignment: Alignment.topCenter, 43 | child: Leader( 44 | link: _leaderLink, 45 | child: Container( 46 | width: 20, 47 | height: 20, 48 | color: Colors.red, 49 | ), 50 | ), 51 | ), 52 | ), 53 | ], 54 | ), 55 | ), 56 | ), 57 | ), 58 | ), 59 | const Spacer(), 60 | ], 61 | ), 62 | ), 63 | FollowerFadeOutBeyondBoundary( 64 | link: _leaderLink, 65 | boundary: WidgetFollowerBoundary( 66 | boundaryKey: _viewportKey, 67 | devicePixelRatio: MediaQuery.devicePixelRatioOf(context), 68 | ), 69 | child: Follower.withAligner( 70 | link: _leaderLink, 71 | aligner: CupertinoPopoverToolbarAligner(_viewportKey), 72 | child: CupertinoPopoverToolbar( 73 | focalPoint: LeaderMenuFocalPoint(link: _leaderLink), 74 | // height: 54, 75 | children: [ 76 | CupertinoPopoverToolbarMenuItem( 77 | label: 'Cut', 78 | onPressed: () { 79 | print("Pressed 'Cut'"); 80 | }, 81 | ), 82 | CupertinoPopoverToolbarMenuItem( 83 | label: 'Copy', 84 | onPressed: () { 85 | print("Pressed 'Copy'"); 86 | }, 87 | ), 88 | CupertinoPopoverToolbarMenuItem( 89 | label: 'Paste', 90 | onPressed: () { 91 | print("Pressed 'Paste'"); 92 | }, 93 | ), 94 | ], 95 | ), 96 | ), 97 | ), 98 | ], 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /example/lib/demos/inventory_demo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:overlord/overlord.dart'; 3 | 4 | class InventoryDemo extends StatefulWidget { 5 | const InventoryDemo({Key? key}) : super(key: key); 6 | 7 | @override 8 | State createState() => _InventoryDemoState(); 9 | } 10 | 11 | class _InventoryDemoState extends State { 12 | @override 13 | Widget build(BuildContext context) { 14 | return Row( 15 | children: [ 16 | Expanded( 17 | child: Center( 18 | child: CupertinoPopoverToolbar( 19 | focalPoint: const StationaryMenuFocalPoint(Offset.zero), 20 | children: _toolbarMenuItems, 21 | ), 22 | ), 23 | ), 24 | const Expanded( 25 | child: Center( 26 | child: CupertinoPopoverMenu( 27 | focalPoint: StationaryMenuFocalPoint(Offset.zero), 28 | padding: EdgeInsets.all(16), 29 | child: Text("Popover Menu"), 30 | ), 31 | ), 32 | ), 33 | ], 34 | ); 35 | } 36 | } 37 | 38 | final _toolbarMenuItems = [ 39 | CupertinoPopoverToolbarMenuItem( 40 | label: 'Style', 41 | onPressed: () { 42 | // ignore: avoid_print 43 | print("Tapped 'style'"); 44 | }, 45 | ), 46 | CupertinoPopoverToolbarMenuItem( 47 | label: 'Duplicate', 48 | onPressed: () { 49 | // ignore: avoid_print 50 | print("Tapped 'duplicate'"); 51 | }, 52 | ), 53 | CupertinoPopoverToolbarMenuItem( 54 | label: 'Cut', 55 | onPressed: () { 56 | // ignore: avoid_print 57 | print("Tapped 'cut'"); 58 | }, 59 | ), 60 | CupertinoPopoverToolbarMenuItem( 61 | label: 'Copy', 62 | onPressed: () { 63 | // ignore: avoid_print 64 | print("Tapped 'copy'"); 65 | }, 66 | ), 67 | CupertinoPopoverToolbarMenuItem( 68 | label: 'Paste', 69 | onPressed: () { 70 | // ignore: avoid_print 71 | print("Tapped 'paste'"); 72 | }, 73 | ), 74 | ]; 75 | -------------------------------------------------------------------------------- /example/lib/infrastructure/ball_sandbox.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/scheduler.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | 5 | /// A [BallSandbox], with a ball that bounces around the screen 6 | /// with a given [follower]. 7 | class BouncingBallSandbox extends StatefulWidget { 8 | const BouncingBallSandbox({ 9 | super.key, 10 | required this.boundsKey, 11 | required this.leaderKey, 12 | required this.followerKey, 13 | required this.followerAligner, 14 | required this.follower, 15 | this.initialBallOffset = Offset.zero, 16 | this.onBallMove, 17 | }); 18 | 19 | final GlobalKey boundsKey; 20 | final GlobalKey leaderKey; 21 | final GlobalKey followerKey; 22 | final FollowerAligner followerAligner; 23 | final Widget follower; 24 | final Offset initialBallOffset; 25 | final void Function(Offset)? onBallMove; 26 | 27 | @override 28 | State createState() => _BouncingBallSandboxState(); 29 | } 30 | 31 | class _BouncingBallSandboxState extends State with SingleTickerProviderStateMixin { 32 | /// Initial velocity of the leader. 33 | final Offset _initialVelocity = const Offset(300, 300); 34 | 35 | /// Current velocity of the leader. 36 | /// 37 | /// The velocity is updated whenever the leader hits an edge of the screen. 38 | late Offset _velocity; 39 | 40 | /// Last [Duration] given by the ticker. 41 | Duration? _lastElapsed; 42 | 43 | /// Current offset of the leader. 44 | /// 45 | /// The offset changes at every tick. 46 | late Offset _ballOffset; 47 | 48 | late Ticker ticker; 49 | 50 | @override 51 | void initState() { 52 | super.initState(); 53 | ticker = createTicker(_onTick)..start(); 54 | _velocity = _initialVelocity; 55 | _ballOffset = widget.initialBallOffset; 56 | } 57 | 58 | @override 59 | void dispose() { 60 | ticker.dispose(); 61 | super.dispose(); 62 | } 63 | 64 | void _onTick(Duration elapsed) { 65 | if (_lastElapsed == null) { 66 | _lastElapsed = elapsed; 67 | return; 68 | } 69 | 70 | final dt = elapsed.inMilliseconds - _lastElapsed!.inMilliseconds; 71 | _lastElapsed = elapsed; 72 | 73 | final bounds = (widget.boundsKey.currentContext?.findRenderObject() as RenderBox?)?.size ?? Size.zero; 74 | 75 | // Offset where the leader hits the right edge. 76 | final maximumLeaderHorizontalOffset = bounds.width - _ballRadius * 2; 77 | 78 | // Offset where the leader hits the bottom edge. 79 | final maximumLeaderVerticalOffset = bounds.height - _ballRadius * 2; 80 | 81 | // Travelled distance between the last tick and the current. 82 | final distance = _velocity * (dt / 1000.0); 83 | 84 | Offset newOffset = _ballOffset + distance; 85 | 86 | // Check for hits. 87 | 88 | if (newOffset.dx > maximumLeaderHorizontalOffset) { 89 | // The ball hit the right edge. 90 | _velocity = Offset(-_velocity.dx, _velocity.dy); 91 | newOffset = Offset(maximumLeaderHorizontalOffset, newOffset.dy); 92 | } 93 | 94 | if (newOffset.dx <= 0) { 95 | // The ball hit the left edge. 96 | _velocity = Offset(-_velocity.dx, _velocity.dy); 97 | newOffset = Offset(0, newOffset.dy); 98 | } 99 | 100 | if (newOffset.dy > maximumLeaderVerticalOffset) { 101 | // The ball hit the bottom. 102 | _velocity = Offset(_velocity.dx, -_velocity.dy); 103 | newOffset = Offset(newOffset.dx, maximumLeaderVerticalOffset); 104 | } 105 | 106 | if (newOffset.dy <= 0) { 107 | // The ball hit the top. 108 | _velocity = Offset(_velocity.dx, -_velocity.dy); 109 | newOffset = Offset(newOffset.dx, 0); 110 | } 111 | 112 | setState(() { 113 | // Update the ball offset before updating the menu focal point. 114 | _ballOffset = newOffset; 115 | widget.onBallMove?.call(_ballOffset); 116 | }); 117 | } 118 | 119 | @override 120 | Widget build(BuildContext context) { 121 | return BallSandbox( 122 | boundsKey: widget.boundsKey, 123 | leaderKey: widget.leaderKey, 124 | followerKey: widget.followerKey, 125 | ballOffset: _ballOffset, 126 | followerAligner: widget.followerAligner, 127 | follower: widget.follower, 128 | ); 129 | } 130 | } 131 | 132 | /// A [BallSandbox], which lets the user drag the ball around the 133 | /// screen with a given [follower]. 134 | class DraggableBallSandbox extends StatefulWidget { 135 | const DraggableBallSandbox({ 136 | super.key, 137 | required this.boundsKey, 138 | required this.leaderKey, 139 | required this.followerKey, 140 | required this.followerAligner, 141 | required this.follower, 142 | this.initialBallOffset = Offset.zero, 143 | this.onBallMove, 144 | }); 145 | 146 | final GlobalKey boundsKey; 147 | final GlobalKey leaderKey; 148 | final GlobalKey followerKey; 149 | final FollowerAligner followerAligner; 150 | final Widget follower; 151 | final Offset initialBallOffset; 152 | final void Function(Offset)? onBallMove; 153 | 154 | @override 155 | State createState() => _DraggableBallSandboxState(); 156 | } 157 | 158 | class _DraggableBallSandboxState extends State { 159 | /// The (x,y) position of the draggable object, which is also our `Leader`. 160 | late Offset _ballOffset; 161 | 162 | @override 163 | void initState() { 164 | super.initState(); 165 | _ballOffset = widget.initialBallOffset; 166 | } 167 | 168 | void _onPanUpdate(DragUpdateDetails details) { 169 | setState(() { 170 | // Update _draggableOffset before updating the menu focal point 171 | _ballOffset += details.delta; 172 | widget.onBallMove?.call(_ballOffset); 173 | }); 174 | } 175 | 176 | @override 177 | Widget build(BuildContext context) { 178 | return BallSandbox( 179 | boundsKey: widget.boundsKey, 180 | leaderKey: widget.leaderKey, 181 | followerKey: widget.followerKey, 182 | ballOffset: _ballOffset, 183 | ballDecorator: (ball) { 184 | return GestureDetector( 185 | onPanUpdate: _onPanUpdate, 186 | child: ball, 187 | ); 188 | }, 189 | followerAligner: widget.followerAligner, 190 | follower: widget.follower, 191 | ); 192 | } 193 | } 194 | 195 | /// Displays a ball with an associated follower. 196 | /// 197 | /// The ball can be given any offset, and the ball can be decorated 198 | /// with another widget, such as a `GestureDetector`. 199 | class BallSandbox extends StatefulWidget { 200 | const BallSandbox({ 201 | super.key, 202 | required this.boundsKey, 203 | required this.leaderKey, 204 | required this.followerKey, 205 | required this.ballOffset, 206 | this.ballDecorator, 207 | required this.followerAligner, 208 | required this.follower, 209 | }); 210 | 211 | final GlobalKey boundsKey; 212 | final GlobalKey leaderKey; 213 | final GlobalKey followerKey; 214 | final Offset ballOffset; 215 | final Widget Function(Widget ball)? ballDecorator; 216 | final FollowerAligner followerAligner; 217 | final Widget follower; 218 | 219 | @override 220 | State createState() => _BallSandboxState(); 221 | } 222 | 223 | class _BallSandboxState extends State { 224 | /// Links the [Leader] and the [Follower]. 225 | late LeaderLink _leaderLink; 226 | 227 | @override 228 | void initState() { 229 | super.initState(); 230 | _leaderLink = LeaderLink(); 231 | } 232 | 233 | @override 234 | Widget build(BuildContext context) { 235 | return Stack( 236 | key: widget.boundsKey, 237 | children: [ 238 | _buildLeader(), 239 | _buildFollower(), 240 | ], 241 | ); 242 | } 243 | 244 | Widget _buildLeader() { 245 | return Positioned( 246 | left: widget.ballOffset.dx, 247 | top: widget.ballOffset.dy, 248 | child: Leader( 249 | key: widget.leaderKey, 250 | link: _leaderLink, 251 | child: widget.ballDecorator != null // 252 | ? widget.ballDecorator!.call(_buildBall()) // 253 | : _buildBall(), 254 | ), 255 | ); 256 | } 257 | 258 | Widget _buildBall() { 259 | return Container( 260 | height: _ballRadius * 2, 261 | width: _ballRadius * 2, 262 | decoration: const BoxDecoration( 263 | shape: BoxShape.circle, 264 | color: Colors.black, 265 | ), 266 | ); 267 | } 268 | 269 | Widget _buildFollower() { 270 | return Positioned( 271 | left: 0, 272 | top: 0, 273 | child: Follower.withAligner( 274 | key: widget.followerKey, 275 | link: _leaderLink, 276 | aligner: widget.followerAligner, 277 | boundary: WidgetFollowerBoundary( 278 | boundaryKey: widget.boundsKey, 279 | devicePixelRatio: MediaQuery.devicePixelRatioOf(context), 280 | ), 281 | child: widget.follower, 282 | ), 283 | ); 284 | } 285 | } 286 | 287 | const double _ballRadius = 50.0; 288 | -------------------------------------------------------------------------------- /example/lib/infrastructure/button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class Button extends StatefulWidget { 5 | const Button({ 6 | super.key, 7 | this.focusNode, 8 | this.padding = EdgeInsets.zero, 9 | this.background = Colors.transparent, 10 | this.backgroundOnHover, 11 | this.backgroundOnPress, 12 | this.border, 13 | this.borderOnHover, 14 | this.borderOnPress, 15 | this.borderRadius = BorderRadius.zero, 16 | this.enabled = true, 17 | this.onPressed, 18 | required this.child, 19 | }); 20 | 21 | final FocusNode? focusNode; 22 | 23 | final EdgeInsets padding; 24 | 25 | final Color background; 26 | 27 | final Color? backgroundOnHover; 28 | 29 | final Color? backgroundOnPress; 30 | 31 | final BorderSide? border; 32 | 33 | final BorderSide? borderOnHover; 34 | 35 | final BorderSide? borderOnPress; 36 | 37 | final BorderRadius borderRadius; 38 | 39 | final bool enabled; 40 | 41 | final VoidCallback? onPressed; 42 | 43 | final Widget child; 44 | 45 | @override 46 | State