├── .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 |
3 |
4 |
5 |
6 |
7 |
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