mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Add RawMenuAnchor animation callbacks (#167806)
Alternative to https://github.com/flutter/flutter/pull/163481, https://github.com/flutter/flutter/pull/167537, https://github.com/flutter/flutter/pull/163481 that uses callbacks. @dkwingsmt - you inspired me to simplify the menu behavior. I didn't end up using Actions, mainly because nested behavior was unwieldy and capturing BuildContext has drawbacks. This uses a basic callback mechanism to animate the menu open and closed. Check out the examples. <hr /> ### The problem RawMenuAnchor synchronously shows or hides an overlay menu in response to `MenuController.open()` and `MenuController.close`, respectively. Because animations cannot be run on a hidden overlay, there currently is no way for developers to add animations to RawMenuAnchor and its subclasses (MenuAnchor, DropdownMenuButton, etc). ### The solution This PR: - Adds two callbacks -- `onOpenRequested` and `onCloseRequested` -- to RawMenuAnchor. - onOpenRequested is called with a position and a showOverlay callback, which opens the menu when called. - onCloseRequested is called with a hideOverlay callback, which hides the menu when called. When `MenuController.open()` and `MenuController.close()` are called, onOpenRequested and onCloseRequested are invoked, respectively. Precursor for https://github.com/flutter/flutter/pull/143416, https://github.com/flutter/flutter/issues/135025, https://github.com/flutter/flutter/pull/143712 ## Demo https://github.com/user-attachments/assets/bb14abca-af26-45fe-8d45-289b5d07dab2 ```dart // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: public_member_api_docs import 'dart:ui' as ui; import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor, RawMenuOverlayInfo; import 'raw_menu_anchor.dart'; /// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using /// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested]. void main() { runApp(const App()); } class Menu extends StatefulWidget { const Menu({super.key}); @override State<Menu> createState() => _MenuState(); } class _MenuState extends State<Menu> with SingleTickerProviderStateMixin { late final AnimationController animationController; final MenuController menuController = MenuController(); @override void initState() { super.initState(); animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); } @override void dispose() { animationController.dispose(); super.dispose(); } void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) { // Mount or reposition the menu before animating the menu open. showOverlay(position: position); if (animationController.isForwardOrCompleted) { // If the menu is already open or opening, the animation is already // running forward. return; } // Animate the menu into view. This will cancel the closing animation. animationController.forward(); } void _handleMenuCloseRequest(VoidCallback hideOverlay) { if (!animationController.isForwardOrCompleted) { // If the menu is already closed or closing, do nothing. return; } // Animate the menu out of view. // // Be sure to use `whenComplete` so that the closing animation // can be interrupted by an opening animation. animationController.reverse().whenComplete(() { if (mounted) { // Hide the menu after the menu has closed hideOverlay(); } }); } @override Widget build(BuildContext context) { return RawMenuAnchor( controller: menuController, onOpenRequested: _handleMenuOpenRequest, onCloseRequested: _handleMenuCloseRequest, overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) { final ui.Offset position = info.anchorRect.bottomLeft; return Positioned( top: position.dy + 5, left: position.dx, child: TapRegion( groupId: info.tapRegionGroupId, child: Material( color: ColorScheme.of(context).primaryContainer, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 3, child: SizeTransition( sizeFactor: animationController, child: const SizedBox( height: 200, width: 150, child: Center(child: Text('Howdy', textAlign: TextAlign.center)), ), ), ), ), ); }, builder: (BuildContext context, MenuController menuController, Widget? child) { return FilledButton( onPressed: () { if (animationController.isForwardOrCompleted) { menuController.close(); } else { menuController.open(); } }, child: const Text('Toggle Menu'), ); }, ); } } class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)), home: const Scaffold(body: Center(child: Menu())), ); } } ``` ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
This commit is contained in:
parent
12e4a898dc
commit
dfadc91433
160
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart
Normal file
160
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart
Normal file
@ -0,0 +1,160 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
|
||||
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
|
||||
void main() {
|
||||
runApp(const RawMenuAnchorAnimationApp());
|
||||
}
|
||||
|
||||
class RawMenuAnchorAnimationExample extends StatefulWidget {
|
||||
const RawMenuAnchorAnimationExample({super.key});
|
||||
|
||||
@override
|
||||
State<RawMenuAnchorAnimationExample> createState() => _RawMenuAnchorAnimationExampleState();
|
||||
}
|
||||
|
||||
class _RawMenuAnchorAnimationExampleState extends State<RawMenuAnchorAnimationExample>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController animationController;
|
||||
final MenuController menuController = MenuController();
|
||||
AnimationStatus get _animationStatus => animationController.status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
)..addStatusListener((AnimationStatus status) {
|
||||
setState(() {
|
||||
// Rebuild to reflect animation status changes on the UI.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
|
||||
// Mount or reposition the menu before animating the menu open.
|
||||
showOverlay();
|
||||
|
||||
if (_animationStatus.isForwardOrCompleted) {
|
||||
// If the menu is already open or opening, the animation is already
|
||||
// running forward.
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate the menu into view.
|
||||
animationController.forward();
|
||||
}
|
||||
|
||||
void _handleMenuCloseRequest(VoidCallback hideOverlay) {
|
||||
if (!_animationStatus.isForwardOrCompleted) {
|
||||
// If the menu is already closed or closing, do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate the menu out of view.
|
||||
animationController.reverse().whenComplete(hideOverlay);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawMenuAnchor(
|
||||
controller: menuController,
|
||||
onOpenRequested: _handleMenuOpenRequest,
|
||||
onCloseRequested: _handleMenuCloseRequest,
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
// Center the menu below the anchor.
|
||||
final Offset position = info.anchorRect.bottomCenter.translate(-75, 4);
|
||||
final ColorScheme colorScheme = ColorScheme.of(context);
|
||||
return Positioned(
|
||||
top: position.dy,
|
||||
left: position.dx,
|
||||
child: Semantics(
|
||||
explicitChildNodes: true,
|
||||
scopesRoute: true,
|
||||
child: ExcludeFocus(
|
||||
excluding: !_animationStatus.isForwardOrCompleted,
|
||||
child: TapRegion(
|
||||
groupId: info.tapRegionGroupId,
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
menuController.close();
|
||||
},
|
||||
child: ScaleTransition(
|
||||
scale: animationController.view,
|
||||
child: FadeTransition(
|
||||
opacity: animationController.drive(
|
||||
Animatable<double>.fromCallback((double value) => clampDouble(value, 0, 1)),
|
||||
),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: colorScheme.primary,
|
||||
child: SizeTransition(
|
||||
axisAlignment: -1,
|
||||
sizeFactor: animationController.view,
|
||||
fixedCrossAxisSizeFactor: 1.0,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
width: 150,
|
||||
child: Text(
|
||||
'ANIMATION STATUS:\n${_animationStatus.name}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: colorScheme.onPrimary, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (BuildContext context, MenuController menuController, Widget? child) {
|
||||
return FilledButton(
|
||||
onPressed: () {
|
||||
if (_animationStatus.isForwardOrCompleted) {
|
||||
menuController.close();
|
||||
} else {
|
||||
menuController.open();
|
||||
}
|
||||
},
|
||||
child: _animationStatus.isForwardOrCompleted ? const Text('Close') : const Text('Open'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RawMenuAnchorAnimationApp extends StatelessWidget {
|
||||
const RawMenuAnchorAnimationApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData.from(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
|
||||
),
|
||||
),
|
||||
home: const Scaffold(body: Center(child: RawMenuAnchorAnimationExample())),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart
Normal file
245
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart
Normal file
@ -0,0 +1,245 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
/// Flutter code sample for a [RawMenuAnchor] that animates a nested menu using
|
||||
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
|
||||
void main() {
|
||||
runApp(const RawMenuAnchorSubmenuAnimationApp());
|
||||
}
|
||||
|
||||
/// Signature for the function that builds a [Menu]'s contents.
|
||||
///
|
||||
/// The [animationStatus] parameter indicates the current state of the menu
|
||||
/// animation, which can be used to adjust the appearance of the menu panel.
|
||||
typedef MenuPanelBuilder = Widget Function(BuildContext context, AnimationStatus animationStatus);
|
||||
|
||||
/// Signature for the function that builds a [Menu]'s anchor button.
|
||||
///
|
||||
/// The [MenuController] can be used to open and close the menu.
|
||||
///
|
||||
/// The [animationStatus] indicates the current state of the menu animation,
|
||||
/// which can be used to adjust the appearance of the menu panel.
|
||||
typedef MenuButtonBuilder =
|
||||
Widget Function(
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
AnimationStatus animationStatus,
|
||||
);
|
||||
|
||||
class RawMenuAnchorSubmenuAnimationExample extends StatelessWidget {
|
||||
const RawMenuAnchorSubmenuAnimationExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Menu(
|
||||
panelBuilder: (BuildContext context, AnimationStatus animationStatus) {
|
||||
final MenuController rootMenuController = MenuController.maybeOf(context)!;
|
||||
return Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
for (int i = 0; i < 4; i++)
|
||||
Menu(
|
||||
panelBuilder: (BuildContext context, AnimationStatus status) {
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
width: 120,
|
||||
child: Center(
|
||||
child: Text('Panel $i:\n${status.name}', textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
},
|
||||
buttonBuilder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
AnimationStatus animationStatus,
|
||||
) {
|
||||
return MenuItemButton(
|
||||
onFocusChange: (bool focused) {
|
||||
if (focused) {
|
||||
rootMenuController.closeChildren();
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
onPressed: () {
|
||||
if (!animationStatus.isForwardOrCompleted) {
|
||||
rootMenuController.closeChildren();
|
||||
controller.open();
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
trailingIcon: const Text('▶'),
|
||||
child: Text('Submenu $i'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
buttonBuilder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
AnimationStatus animationStatus,
|
||||
) {
|
||||
return FilledButton(
|
||||
onPressed: () {
|
||||
if (animationStatus.isForwardOrCompleted) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: const Text('Menu'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Menu extends StatefulWidget {
|
||||
const Menu({super.key, required this.panelBuilder, required this.buttonBuilder});
|
||||
final MenuPanelBuilder panelBuilder;
|
||||
final MenuButtonBuilder buttonBuilder;
|
||||
|
||||
@override
|
||||
State<Menu> createState() => MenuState();
|
||||
}
|
||||
|
||||
class MenuState extends State<Menu> with SingleTickerProviderStateMixin {
|
||||
final MenuController menuController = MenuController();
|
||||
late final AnimationController animationController;
|
||||
late final CurvedAnimation animation;
|
||||
bool get isSubmenu => MenuController.maybeOf(context) != null;
|
||||
AnimationStatus get animationStatus => animationController.status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
)..addStatusListener((AnimationStatus status) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// Rebuild to reflect animation status changes.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
animation = CurvedAnimation(parent: animationController, curve: Curves.easeOutQuart);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
animationController.dispose();
|
||||
animation.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
|
||||
// Mount or reposition the menu before animating the menu open.
|
||||
showOverlay();
|
||||
|
||||
if (animationStatus.isForwardOrCompleted) {
|
||||
// If the menu is already open or opening, the animation is already
|
||||
// running forward.
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate the menu into view.
|
||||
animationController.forward();
|
||||
}
|
||||
|
||||
void _handleMenuCloseRequest(VoidCallback hideOverlay) {
|
||||
if (!animationStatus.isForwardOrCompleted) {
|
||||
// If the menu is already closed or closing, do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate the menu's children out of view.
|
||||
menuController.closeChildren();
|
||||
|
||||
// Animate the menu out of view.
|
||||
animationController.reverse().whenComplete(hideOverlay);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
role: SemanticsRole.menu,
|
||||
child: RawMenuAnchor(
|
||||
controller: menuController,
|
||||
onOpenRequested: _handleMenuOpenRequest,
|
||||
onCloseRequested: _handleMenuCloseRequest,
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
final ui.Offset position =
|
||||
isSubmenu ? info.anchorRect.topRight : info.anchorRect.bottomLeft;
|
||||
final ColorScheme colorScheme = ColorScheme.of(context);
|
||||
return Positioned(
|
||||
top: position.dy,
|
||||
left: position.dx,
|
||||
child: Semantics(
|
||||
explicitChildNodes: true,
|
||||
scopesRoute: true,
|
||||
// Remove focus while the menu is closing.
|
||||
child: ExcludeFocus(
|
||||
excluding: !animationStatus.isForwardOrCompleted,
|
||||
child: TapRegion(
|
||||
groupId: info.tapRegionGroupId,
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
menuController.close();
|
||||
},
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
shadowColor: colorScheme.shadow,
|
||||
child: SizeTransition(
|
||||
axisAlignment: position.dx < 0 ? 1 : -1,
|
||||
sizeFactor: animation,
|
||||
fixedCrossAxisSizeFactor: 1.0,
|
||||
child: widget.panelBuilder(context, animationStatus),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (BuildContext context, MenuController controller, Widget? child) {
|
||||
return widget.buttonBuilder(context, controller, animationStatus);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RawMenuAnchorSubmenuAnimationApp extends StatelessWidget {
|
||||
const RawMenuAnchorSubmenuAnimationApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData.from(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
|
||||
),
|
||||
),
|
||||
home: const Scaffold(body: Center(child: RawMenuAnchorSubmenuAnimationExample())),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.2.dart' as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Menu opens and closes', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorAnimationApp());
|
||||
|
||||
// Open the menu.
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
|
||||
final Finder message =
|
||||
find
|
||||
.ancestor(of: find.textContaining('ANIMATION STATUS:'), matching: find.byType(Material))
|
||||
.first;
|
||||
|
||||
expect(find.text('Open'), findsNothing);
|
||||
expect(find.text('Close'), findsOneWidget);
|
||||
expect(message, findsOneWidget);
|
||||
expect(tester.getRect(message), const Rect.fromLTRB(400.0, 328.0, 400.0, 328.0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(
|
||||
tester.getRect(message),
|
||||
rectMoreOrLessEquals(const Rect.fromLTRB(325.0, 328.0, 475.0, 528.0), epsilon: 0.1),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1100));
|
||||
|
||||
expect(
|
||||
tester.getRect(message),
|
||||
rectMoreOrLessEquals(const Rect.fromLTRB(325.0, 328.0, 475.0, 528.0), epsilon: 0.1),
|
||||
);
|
||||
|
||||
// Close the menu.
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
expect(
|
||||
tester.getRect(message),
|
||||
rectMoreOrLessEquals(const Rect.fromLTRB(362.5, 353.0, 437.5, 403.0), epsilon: 0.1),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 920));
|
||||
|
||||
expect(find.widgetWithText(Material, 'ANIMATION STATUS:'), findsNothing);
|
||||
expect(find.text('Close'), findsNothing);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Panel text contains the animation status', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorAnimationApp());
|
||||
|
||||
// Open the menu.
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('forward'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1100));
|
||||
|
||||
expect(find.textContaining('completed'), findsOneWidget);
|
||||
|
||||
await tester.tapAt(Offset.zero);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('reverse'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 920));
|
||||
|
||||
// The panel text should be removed when the animation is dismissed.
|
||||
expect(find.textContaining('reverse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Menu closes on outside tap', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorAnimationApp());
|
||||
|
||||
// Open the menu.
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Close'), findsOneWidget);
|
||||
|
||||
// Tap outside the menu to close it.
|
||||
await tester.tapAt(Offset.zero);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Close'), findsNothing);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.3.dart' as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
String getPanelText(int i, AnimationStatus status) => 'Panel $i:\n${status.name}';
|
||||
|
||||
Future<TestGesture> hoverOver(WidgetTester tester, Offset location) async {
|
||||
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.moveTo(location);
|
||||
return gesture;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('Root menu opens when anchor button is pressed', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
|
||||
|
||||
final Finder button = find.byType(FilledButton);
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
|
||||
final Finder panel =
|
||||
find
|
||||
.ancestor(of: find.textContaining('Submenu 0'), matching: find.byType(ExcludeFocus))
|
||||
.first;
|
||||
|
||||
expect(
|
||||
tester.getRect(panel),
|
||||
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 324.0), epsilon: 0.1),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
tester.getRect(panel),
|
||||
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 499.7), epsilon: 0.1),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 101));
|
||||
expect(
|
||||
tester.getRect(panel),
|
||||
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 516.0), epsilon: 0.1),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Submenu'), findsNWidgets(4));
|
||||
});
|
||||
|
||||
testWidgets('Hover traversal opens one submenu at a time', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
|
||||
|
||||
// Open root menu.
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu 0').first;
|
||||
await hoverOver(tester, tester.getCenter(menuItem));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 201));
|
||||
|
||||
for (int i = 1; i < 4; i++) {
|
||||
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu $i').first;
|
||||
await hoverOver(tester, tester.getCenter(menuItem));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(getPanelText(i - 1, AnimationStatus.reverse)), findsOneWidget);
|
||||
expect(find.text(getPanelText(i, AnimationStatus.forward)), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 201));
|
||||
|
||||
expect(find.text(getPanelText(i - 1, AnimationStatus.dismissed)), findsNothing);
|
||||
expect(find.text(getPanelText(i, AnimationStatus.completed)), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Submenu opens at expected rate', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
|
||||
|
||||
// Open root menu.
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 201));
|
||||
|
||||
// Start hovering over submenu item
|
||||
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu 0').first;
|
||||
await hoverOver(tester, tester.getCenter(menuItem));
|
||||
await tester.pump();
|
||||
|
||||
final Finder panel =
|
||||
find
|
||||
.ancestor(of: find.textContaining('Panel 0'), matching: find.byType(ExcludeFocus))
|
||||
.first;
|
||||
|
||||
// 25% through, 70% height
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(tester.getSize(panel).height, moreOrLessEquals(0.7 * 120, epsilon: 1));
|
||||
|
||||
// 50% through, 91.5% height
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(tester.getSize(panel).height, moreOrLessEquals(0.91 * 120, epsilon: 1));
|
||||
|
||||
// 100% through, full height
|
||||
await tester.pump(const Duration(milliseconds: 101));
|
||||
expect(tester.getSize(panel).height, moreOrLessEquals(120, epsilon: 1));
|
||||
|
||||
// Close submenu
|
||||
final Finder menuItem1 = find.widgetWithText(MenuItemButton, 'Submenu 1').first;
|
||||
await hoverOver(tester, tester.getCenter(menuItem1));
|
||||
await tester.pump();
|
||||
|
||||
// 25% through, ~98% height
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(tester.getSize(panel).height, moreOrLessEquals(0.98 * 120, epsilon: 1));
|
||||
|
||||
// 50% through, ~91.5% height
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(tester.getSize(panel).height, moreOrLessEquals(0.91 * 120, epsilon: 1));
|
||||
});
|
||||
|
||||
testWidgets('Outside tap closes all menus', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
|
||||
|
||||
// Open root menu and submenu.
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 201));
|
||||
|
||||
await hoverOver(
|
||||
tester,
|
||||
tester.getCenter(find.widgetWithText(MenuItemButton, 'Submenu 0').first),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 201));
|
||||
|
||||
expect(find.textContaining(AnimationStatus.completed.name), findsOneWidget);
|
||||
expect(find.textContaining(AnimationStatus.reverse.name), findsNothing);
|
||||
|
||||
// Tap outside
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining(AnimationStatus.completed.name), findsNothing);
|
||||
expect(find.textContaining(AnimationStatus.reverse.name), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -117,7 +117,20 @@ typedef RawMenuAnchorOverlayBuilder =
|
||||
typedef RawMenuAnchorChildBuilder =
|
||||
Widget Function(BuildContext context, MenuController controller, Widget? child);
|
||||
|
||||
// An [InheritedWidget] used to notify anchor descendants when a menu opens
|
||||
/// Signature for the callback used by [RawMenuAnchor.onOpenRequested] to
|
||||
/// intercept requests to open a menu.
|
||||
///
|
||||
/// See [RawMenuAnchor.onOpenRequested] for more information.
|
||||
typedef RawMenuAnchorOpenRequestedCallback =
|
||||
void Function(Offset? position, VoidCallback showOverlay);
|
||||
|
||||
/// Signature for the callback used by [RawMenuAnchor.onCloseRequested] to
|
||||
/// intercept requests to close a menu.
|
||||
///
|
||||
/// See [RawMenuAnchor.onCloseRequested] for more information.
|
||||
typedef RawMenuAnchorCloseRequestedCallback = void Function(VoidCallback hideOverlay);
|
||||
|
||||
// An InheritedWidget used to notify anchor descendants when a menu opens
|
||||
// and closes, and to pass the anchor's controller to descendants.
|
||||
class _MenuControllerScope extends InheritedWidget {
|
||||
const _MenuControllerScope({
|
||||
@ -153,31 +166,70 @@ class _MenuControllerScope extends InheritedWidget {
|
||||
/// If [MenuController.open] is called with a `position` argument, it will be
|
||||
/// passed to the `info` argument of the `overlayBuilder` function.
|
||||
///
|
||||
/// Users are responsible for managing the positioning, semantics, and focus of
|
||||
/// the menu.
|
||||
/// The [RawMenuAnchor] does not manage semantics and focus of the menu.
|
||||
///
|
||||
/// To programmatically control a [RawMenuAnchor], like opening or closing it, or checking its state,
|
||||
/// you can get its associated [MenuController]. Use `MenuController.maybeOf(BuildContext context)`
|
||||
/// to retrieve the controller for the closest [RawMenuAnchor] ancestor of a given `BuildContext`.
|
||||
/// More detailed usage of [MenuController] is available in its class documentation.
|
||||
/// ### Adding animations to menus
|
||||
///
|
||||
/// A [RawMenuAnchor] has no knowledge of animations, as evident from its APIs,
|
||||
/// which don't involve [AnimationController] at all. It only knows whether the
|
||||
/// overlay is shown or hidden.
|
||||
///
|
||||
/// If another widget intends to implement a menu with opening and closing
|
||||
/// transitions, [RawMenuAnchor]'s overlay should remain visible throughout both
|
||||
/// the opening and closing animation durations.
|
||||
///
|
||||
/// This means that the `showOverlay` callback passed to [onOpenRequested]
|
||||
/// should be called before the first frame of the opening animation.
|
||||
/// Conversely, `hideOverlay` within [onCloseRequested] should only be called
|
||||
/// after the closing animation has completed.
|
||||
///
|
||||
/// This also means that, if [MenuController.open] is called while the overlay
|
||||
/// is already visible, [RawMenuAnchor] has no way of knowing whether the menu
|
||||
/// is currently opening, closing, or stably displayed. The parent widget will
|
||||
/// need to manage additional information (such as the state of an
|
||||
/// [AnimationController]) to determine how to respond in such scenarios.
|
||||
///
|
||||
/// To programmatically control a [RawMenuAnchor], like opening or closing it,
|
||||
/// or checking its state, you can get its associated [MenuController]. Use
|
||||
/// `MenuController.maybeOf(BuildContext context)` to retrieve the controller
|
||||
/// for the closest [RawMenuAnchor] ancestor of a given `BuildContext`. More
|
||||
/// detailed usage of [MenuController] is available in its class documentation.
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
///
|
||||
/// This example uses a [RawMenuAnchor] to build a basic select menu with
|
||||
/// four items.
|
||||
/// This example uses a [RawMenuAnchor] to build a basic select menu with four
|
||||
/// items.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
///
|
||||
/// This example uses [RawMenuAnchor.onOpenRequested] and
|
||||
/// [RawMenuAnchor.onCloseRequested] to build an animated menu.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
///
|
||||
/// This example uses [RawMenuAnchor.onOpenRequested] and
|
||||
/// [RawMenuAnchor.onCloseRequested] to build an animated nested menu.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart **
|
||||
/// {@end-tool}
|
||||
class RawMenuAnchor extends StatefulWidget {
|
||||
/// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder].
|
||||
///
|
||||
/// The [overlayBuilder] should not be null.
|
||||
/// The [overlayBuilder] must not be null.
|
||||
const RawMenuAnchor({
|
||||
super.key,
|
||||
this.childFocusNode,
|
||||
this.consumeOutsideTaps = false,
|
||||
this.onOpen,
|
||||
this.onClose,
|
||||
this.onOpenRequested = _defaultOnOpenRequested,
|
||||
this.onCloseRequested = _defaultOnCloseRequested,
|
||||
this.useRootOverlay = false,
|
||||
this.builder,
|
||||
required this.controller,
|
||||
@ -185,12 +237,110 @@ class RawMenuAnchor extends StatefulWidget {
|
||||
this.child,
|
||||
});
|
||||
|
||||
/// A callback that is invoked when the menu is opened.
|
||||
/// Called when the menu overlay is shown.
|
||||
///
|
||||
/// When [MenuController.open] is called, [onOpenRequested] is invoked with a
|
||||
/// `showOverlay` callback that, when called, shows the menu overlay and
|
||||
/// triggers [onOpen].
|
||||
///
|
||||
/// The default implementation of [onOpenRequested] calls `showOverlay`
|
||||
/// synchronously, thereby calling [onOpen] synchronously. In this case,
|
||||
/// [onOpen] is called regardless of whether the menu overlay is already
|
||||
/// showing.
|
||||
///
|
||||
/// Custom implementations of [onOpenRequested] can delay the call to
|
||||
/// `showOverlay`, or not call it at all, in which case [onOpen] will not be
|
||||
/// called. Calling `showOverlay` after disposal is a no-op, and will not
|
||||
/// trigger [onOpen].
|
||||
///
|
||||
/// A typical usage is to respond when the menu first becomes interactive,
|
||||
/// such as by setting focus to a menu item.
|
||||
final VoidCallback? onOpen;
|
||||
|
||||
/// A callback that is invoked when the menu is closed.
|
||||
/// Called when the menu overlay is hidden.
|
||||
///
|
||||
/// When [MenuController.close] is called, [onCloseRequested] is invoked with
|
||||
/// a `hideOverlay` callback that, when called, hides the menu overlay and
|
||||
/// triggers [onClose].
|
||||
///
|
||||
/// The default implementation of [onCloseRequested] calls `hideOverlay`
|
||||
/// synchronously, thereby calling [onClose] synchronously. In this case,
|
||||
/// [onClose] is called regardless of whether the menu overlay is already
|
||||
/// hidden.
|
||||
///
|
||||
/// Custom implementations of [onCloseRequested] can delay the call to
|
||||
/// `hideOverlay` or not call it at all, in which case [onClose] will not be
|
||||
/// called. Calling `hideOverlay` after disposal is a no-op, and will not
|
||||
/// trigger [onClose].
|
||||
final VoidCallback? onClose;
|
||||
|
||||
/// Called when a request is made to open the menu.
|
||||
///
|
||||
/// This callback is triggered every time [MenuController.open] is called,
|
||||
/// even when the menu overlay is already showing. As a result, this callback
|
||||
/// is a good place to begin menu opening animations, or observe when a menu
|
||||
/// is repositioned.
|
||||
///
|
||||
/// After an open request is intercepted, the `showOverlay` callback should be
|
||||
/// called when the menu overlay (the widget built by [overlayBuilder]) is
|
||||
/// ready to be shown. This can occur immediately (the default behavior), or
|
||||
/// after a delay. Calling `showOverlay` sets [MenuController.isOpen] to true,
|
||||
/// builds (or rebuilds) the overlay widget, and shows the menu overlay at the
|
||||
/// front of the overlay stack.
|
||||
///
|
||||
/// If `showOverlay` is not called, the menu will stay hidden. Calling
|
||||
/// `showOverlay` after disposal is a no-op, meaning it will not trigger
|
||||
/// [onOpen] or show the menu overlay.
|
||||
///
|
||||
/// If a [RawMenuAnchor] is used in a themed menu that plays an opening
|
||||
/// animation, the themed menu should show the overlay before starting the
|
||||
/// opening animation, since the animation plays on the overlay itself.
|
||||
///
|
||||
/// The `position` argument is the `position` that [MenuController.open] was
|
||||
/// called with.
|
||||
///
|
||||
/// A typical [onOpenRequested] consists of the following steps:
|
||||
///
|
||||
/// 1. Optional delay.
|
||||
/// 2. Call `showOverlay` (whose call chain eventually invokes [onOpen]).
|
||||
/// 3. Optionally start the opening animation.
|
||||
///
|
||||
/// Defaults to a callback that immediately shows the menu.
|
||||
final RawMenuAnchorOpenRequestedCallback onOpenRequested;
|
||||
|
||||
/// Called when a request is made to close the menu.
|
||||
///
|
||||
/// This callback is triggered every time [MenuController.close] is called,
|
||||
/// regardless of whether the overlay is already hidden. As a result, this
|
||||
/// callback can be used to add a delay or a closing animation before the menu
|
||||
/// is hidden.
|
||||
///
|
||||
/// If the menu is not closed, this callback will also be called when the root
|
||||
/// menu anchor is scrolled and when the screen is resized.
|
||||
///
|
||||
/// After a close request is intercepted and closing behaviors have completed,
|
||||
/// the `hideOverlay` callback should be called. This callback sets
|
||||
/// [MenuController.isOpen] to false and hides the menu overlay widget. If the
|
||||
/// [RawMenuAnchor] is used in a themed menu that plays a closing animation,
|
||||
/// `hideOverlay` should be called after the closing animation has ended,
|
||||
/// since the animation plays on the overlay itself. This means that
|
||||
/// [MenuController.isOpen] will stay true while closing animations are
|
||||
/// running.
|
||||
///
|
||||
/// Calling `hideOverlay` after disposal is a no-op, meaning it will not
|
||||
/// trigger [onClose] or hide the menu overlay.
|
||||
///
|
||||
/// Typically, [onCloseRequested] consists of the following steps:
|
||||
///
|
||||
/// 1. Optionally start the closing animation and wait for it to complete.
|
||||
/// 2. Call `hideOverlay` (whose call chain eventually invokes [onClose]).
|
||||
///
|
||||
/// Throughout the closing sequence, menus should typically not be focusable
|
||||
/// or interactive.
|
||||
///
|
||||
/// Defaults to a callback that immediately hides the menu.
|
||||
final RawMenuAnchorCloseRequestedCallback onCloseRequested;
|
||||
|
||||
/// A builder that builds the widget that this [RawMenuAnchor] surrounds.
|
||||
///
|
||||
/// Typically, this is a button used to open the menu by calling
|
||||
@ -208,6 +358,8 @@ class RawMenuAnchor extends StatefulWidget {
|
||||
/// to rebuild this child when those change.
|
||||
final Widget? child;
|
||||
|
||||
/// Called to build and position the menu overlay.
|
||||
///
|
||||
/// The [overlayBuilder] function is passed a [RawMenuOverlayInfo] object that
|
||||
/// defines the anchor's [Rect], the [Size] of the overlay, the
|
||||
/// [TapRegion.groupId] for the menu system, and the position [Offset] passed
|
||||
@ -249,8 +401,8 @@ class RawMenuAnchor extends StatefulWidget {
|
||||
/// If not supplied, the anchor will not retain focus when the menu is opened.
|
||||
final FocusNode? childFocusNode;
|
||||
|
||||
/// Whether or not a tap event that closes the menu will be permitted to
|
||||
/// continue on to the gesture arena.
|
||||
/// Whether a tap event that closes the menu will be permitted to continue on
|
||||
/// to the gesture arena.
|
||||
///
|
||||
/// If false, then tapping outside of a menu when the menu is open will both
|
||||
/// close the menu, and allow the tap to participate in the gesture arena.
|
||||
@ -265,6 +417,14 @@ class RawMenuAnchor extends StatefulWidget {
|
||||
/// widgets.
|
||||
final MenuController controller;
|
||||
|
||||
static void _defaultOnOpenRequested(Offset? position, VoidCallback showOverlay) {
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
static void _defaultOnCloseRequested(VoidCallback hideOverlay) {
|
||||
hideOverlay();
|
||||
}
|
||||
|
||||
@override
|
||||
State<RawMenuAnchor> createState() => _RawMenuAnchorState();
|
||||
|
||||
@ -336,15 +496,18 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
|
||||
_parent?._addChild(this);
|
||||
}
|
||||
|
||||
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll);
|
||||
_scrollPosition = Scrollable.maybeOf(context)?.position;
|
||||
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll);
|
||||
final Size newSize = MediaQuery.sizeOf(context);
|
||||
if (_viewSize != null && newSize != _viewSize) {
|
||||
// Close the menus if the view changes size.
|
||||
root.close();
|
||||
if (isRoot) {
|
||||
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll);
|
||||
_scrollPosition = Scrollable.maybeOf(context)?.position;
|
||||
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll);
|
||||
|
||||
final Size newSize = MediaQuery.sizeOf(context);
|
||||
if (_viewSize != null && newSize != _viewSize && isOpen) {
|
||||
// Close the menus if the view changes size.
|
||||
handleCloseRequest();
|
||||
}
|
||||
_viewSize = newSize;
|
||||
}
|
||||
_viewSize = newSize;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -381,8 +544,8 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
|
||||
// If an ancestor scrolls, and we're a root anchor, then close the menus.
|
||||
// Don't just close it on *any* scroll, since we want to be able to scroll
|
||||
// menus themselves if they're too big for the view.
|
||||
if (isRoot) {
|
||||
close();
|
||||
if (isOpen) {
|
||||
handleCloseRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@ -403,22 +566,56 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
|
||||
|
||||
/// Open the menu, optionally at a position relative to the [RawMenuAnchor].
|
||||
///
|
||||
/// Call this when the menu should be shown to the user.
|
||||
/// Call this when the menu overlay should be shown and added to the widget
|
||||
/// tree.
|
||||
///
|
||||
/// The optional `position` argument should specify the location of the menu in
|
||||
/// the local coordinates of the [RawMenuAnchor].
|
||||
/// The optional `position` argument should specify the location of the menu
|
||||
/// in the local coordinates of the [RawMenuAnchor].
|
||||
@protected
|
||||
void open({Offset? position});
|
||||
|
||||
/// Close the menu.
|
||||
/// Close the menu and all of its children.
|
||||
///
|
||||
/// Call this when the menu overlay should be hidden and removed from the
|
||||
/// widget tree.
|
||||
///
|
||||
/// If `inDispose` is true, this method call was triggered by the widget being
|
||||
/// unmounted.
|
||||
@protected
|
||||
void close({bool inDispose = false});
|
||||
|
||||
/// Implemented by subclasses to define what to do when [MenuController.open]
|
||||
/// is called.
|
||||
///
|
||||
/// This method should not be directly called by subclasses. Its call chain
|
||||
/// should eventually invoke `_RawMenuAnchorBaseMixin.open`
|
||||
@protected
|
||||
void handleOpenRequest({Offset? position});
|
||||
|
||||
/// Implemented by subclasses to define what to do when [MenuController.close]
|
||||
/// is called.
|
||||
///
|
||||
/// This method should not be directly called by subclasses. Its call chain
|
||||
/// should eventually invoke `_RawMenuAnchorBaseMixin.close`.
|
||||
@protected
|
||||
void handleCloseRequest();
|
||||
|
||||
/// Request that the submenus of this menu be closed.
|
||||
///
|
||||
/// By default, this method will call [handleCloseRequest] on each child of this
|
||||
/// menu, which will trigger the closing sequence of each child.
|
||||
///
|
||||
/// If `inDispose` is true, this method was triggered by the widget being
|
||||
/// unmounted.
|
||||
@protected
|
||||
void closeChildren({bool inDispose = false}) {
|
||||
assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}'));
|
||||
for (final _RawMenuAnchorBaseMixin child in List<_RawMenuAnchorBaseMixin>.of(_anchorChildren)) {
|
||||
child.close(inDispose: inDispose);
|
||||
if (inDispose) {
|
||||
child.close(inDispose: inDispose);
|
||||
} else {
|
||||
child.handleCloseRequest();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -428,7 +625,9 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
|
||||
@protected
|
||||
void handleOutsideTap(PointerDownEvent pointerDownEvent) {
|
||||
assert(_debugMenuInfo('Tapped Outside $menuController'));
|
||||
closeChildren();
|
||||
if (isOpen) {
|
||||
closeChildren();
|
||||
}
|
||||
}
|
||||
|
||||
// Used to build the anchor widget in subclasses.
|
||||
@ -457,10 +656,7 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
|
||||
class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMixin<RawMenuAnchor> {
|
||||
// This is the global key that is used later to determine the bounding rect
|
||||
// for the anchor's region that the CustomSingleChildLayout's delegate
|
||||
// uses to determine where to place the menu on the screen and to avoid the
|
||||
// view's edges.
|
||||
// The global key used to determine the bounding rect for the anchor.
|
||||
final GlobalKey _anchorKey = GlobalKey<_RawMenuAnchorState>(
|
||||
debugLabel: kReleaseMode ? null : 'MenuAnchor',
|
||||
);
|
||||
@ -499,14 +695,11 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
|
||||
|
||||
@override
|
||||
void open({Offset? position}) {
|
||||
assert(menuController._anchor == this);
|
||||
if (isOpen) {
|
||||
if (position == _menuPosition) {
|
||||
assert(_debugMenuInfo("Not opening $this because it's already open"));
|
||||
// The menu is open and not being moved, so just return.
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
// The menu is already open, but we need to move to another location, so
|
||||
// close it first.
|
||||
close();
|
||||
@ -517,9 +710,8 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
|
||||
// Close all siblings.
|
||||
_parent?.closeChildren();
|
||||
assert(!_overlayController.isShowing);
|
||||
|
||||
_parent?._childChangedOpenState();
|
||||
_menuPosition = position;
|
||||
_parent?._childChangedOpenState();
|
||||
_overlayController.show();
|
||||
|
||||
if (_isRootOverlayAnchor) {
|
||||
@ -527,17 +719,11 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
|
||||
}
|
||||
|
||||
widget.onOpen?.call();
|
||||
if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
|
||||
setState(() {
|
||||
// Mark dirty to notify MenuController dependents.
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
// Mark dirty to notify MenuController dependents.
|
||||
});
|
||||
}
|
||||
|
||||
// Close the menu.
|
||||
//
|
||||
// Call this when the menu should be closed. Has no effect if the menu is
|
||||
// already closed.
|
||||
@override
|
||||
void close({bool inDispose = false}) {
|
||||
assert(_debugMenuInfo('Closing $this'));
|
||||
@ -569,6 +755,32 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleOpenRequest({ui.Offset? position}) {
|
||||
widget.onOpenRequested(position, () {
|
||||
open(position: position);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCloseRequest() {
|
||||
// Changes in MediaQuery.sizeOf(context) cause RawMenuAnchor to close during
|
||||
// didChangeDependencies. When this happens, calling setState during the
|
||||
// closing sequence (handleCloseRequest -> onCloseRequested -> hideOverlay)
|
||||
// will throw an error, since we'd be scheduling a build during a build. We
|
||||
// avoid this by checking if we're in a build, and if so, we schedule the
|
||||
// close for the next frame.
|
||||
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
|
||||
widget.onCloseRequested(close);
|
||||
} else {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
widget.onCloseRequested(close);
|
||||
}
|
||||
}, debugLabel: 'RawMenuAnchor.handleCloseRequest');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOverlay(BuildContext context) {
|
||||
final BuildContext anchorContext = _anchorKey.currentContext!;
|
||||
final RenderBox overlay =
|
||||
@ -641,7 +853,7 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
|
||||
///
|
||||
/// When a [MenuController] is given to a [RawMenuAnchorGroup],
|
||||
/// - [MenuController.open] has no effect.
|
||||
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open
|
||||
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open.
|
||||
/// - [MenuController.isOpen] reflects whether any child [RawMenuAnchor] is
|
||||
/// open.
|
||||
///
|
||||
@ -734,6 +946,18 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCloseRequest() {
|
||||
assert(_debugMenuInfo('Requesting close $this'));
|
||||
close();
|
||||
}
|
||||
|
||||
@override
|
||||
void handleOpenRequest({ui.Offset? position}) {
|
||||
assert(_debugMenuInfo('Requesting open $this'));
|
||||
open(position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildAnchor(BuildContext context) {
|
||||
return TapRegion(
|
||||
@ -744,8 +968,8 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller used to manage a menu created by a [RawMenuAnchor], or
|
||||
/// [RawMenuAnchorGroup].
|
||||
/// A controller used to manage a menu created by a subclass of [RawMenuAnchor],
|
||||
/// such as [MenuAnchor], [MenuBar], [SubmenuButton].
|
||||
///
|
||||
/// A [MenuController] is used to control and interrogate a menu after it has
|
||||
/// been created, with methods such as [open] and [close], and state accessors
|
||||
@ -756,26 +980,18 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
|
||||
/// [MenuBar], [SubmenuButton], or [RawMenuAnchor]. Doing so will not establish
|
||||
/// a dependency relationship.
|
||||
///
|
||||
/// [MenuController.maybeIsOpenOf] can be used to interrogate the state of a
|
||||
/// menu from the [BuildContext] of a widget that is a descendant of a
|
||||
/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a
|
||||
/// dependency relationship, so the calling widget will rebuild when the menu
|
||||
/// opens and closes, and when the [MenuController] changes.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines.
|
||||
/// * [MenuBar], a widget that creates a menu bar that can take an optional
|
||||
/// [MenuController].
|
||||
/// * [SubmenuButton], a Material widget that has a button that manages a
|
||||
/// submenu.
|
||||
/// * [RawMenuAnchor], a generic widget that manages a submenu.
|
||||
/// * [RawMenuAnchorGroup], a generic widget that wraps a group of submenus.
|
||||
class MenuController {
|
||||
/// The anchor that this controller controls.
|
||||
///
|
||||
/// This is set automatically when a [MenuController] is given to the anchor
|
||||
/// it controls.
|
||||
/// * [SubmenuButton], a widget that has a button that manages a submenu.
|
||||
/// * [RawMenuAnchor], a widget that defines a region that has submenu.
|
||||
final class MenuController {
|
||||
// The anchor that this controller controls.
|
||||
//
|
||||
// This is set automatically when this `MenuController` is attached to an
|
||||
// anchor.
|
||||
_RawMenuAnchorBaseMixin? _anchor;
|
||||
|
||||
/// Whether or not the menu associated with this [MenuController] is open.
|
||||
@ -784,13 +1000,17 @@ class MenuController {
|
||||
/// Opens the menu that this [MenuController] is associated with.
|
||||
///
|
||||
/// If `position` is given, then the menu will open at the position given, in
|
||||
/// the coordinate space of the root overlay.
|
||||
/// the coordinate space of the [RawMenuAnchor] that this controller is
|
||||
/// attached to.
|
||||
///
|
||||
/// If given, the `position` will override the [MenuAnchor.alignmentOffset]
|
||||
/// given to the [MenuAnchor].
|
||||
///
|
||||
/// If the menu's anchor point is scrolled by an ancestor, or the view changes
|
||||
/// size, then any open menus will automatically close.
|
||||
/// size, then any open menu will automatically close.
|
||||
void open({Offset? position}) {
|
||||
assert(_anchor != null);
|
||||
_anchor!.open(position: position);
|
||||
_anchor!.handleOpenRequest(position: position);
|
||||
}
|
||||
|
||||
/// Close the menu that this [MenuController] is associated with.
|
||||
@ -801,7 +1021,7 @@ class MenuController {
|
||||
/// If the menu's anchor point is scrolled by an ancestor, or the view changes
|
||||
/// size, then any open menu will automatically close.
|
||||
void close() {
|
||||
_anchor?.close();
|
||||
_anchor?.handleCloseRequest();
|
||||
}
|
||||
|
||||
/// Close the children of the menu associated with this [MenuController],
|
||||
@ -822,9 +1042,8 @@ class MenuController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] or
|
||||
/// [RawMenuAnchorGroup] nearest to the given `context`, if one exists.
|
||||
/// Otherwise, returns null.
|
||||
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] nearest to
|
||||
/// the given `context`, if one exists. Otherwise, returns null.
|
||||
///
|
||||
/// This method will not establish a dependency relationship, so the calling
|
||||
/// widget will not rebuild when the menu opens and closes, nor when the
|
||||
@ -867,7 +1086,7 @@ class DismissMenuAction extends DismissAction {
|
||||
|
||||
@override
|
||||
void invoke(DismissIntent intent) {
|
||||
controller._anchor!.root.close();
|
||||
controller._anchor!.root.handleCloseRequest();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -612,6 +612,170 @@ void main() {
|
||||
expect(overlayBuilds, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('MenuController can be changed', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
final MenuController groupController = MenuController();
|
||||
|
||||
final MenuController newController = MenuController();
|
||||
final MenuController newGroupController = MenuController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
controller: controller,
|
||||
child: Menu(
|
||||
controller: groupController,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isTrue);
|
||||
expect(groupController.isOpen, isTrue);
|
||||
expect(newController.isOpen, isFalse);
|
||||
expect(newGroupController.isOpen, isFalse);
|
||||
|
||||
// Swap the controllers.
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
controller: newController,
|
||||
child: Menu(
|
||||
controller: newGroupController,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isFalse);
|
||||
expect(groupController.isOpen, isFalse);
|
||||
expect(newController.isOpen, isTrue);
|
||||
expect(newGroupController.isOpen, isTrue);
|
||||
|
||||
// Close the new controller.
|
||||
newController.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(newController.isOpen, isFalse);
|
||||
expect(newGroupController.isOpen, isFalse);
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('[Group] MenuController can be moved to a different menu', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
controller: controller,
|
||||
child: Menu(
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isTrue);
|
||||
|
||||
// Swap the controllers.
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
key: UniqueKey(),
|
||||
controller: controller,
|
||||
child: Menu(
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
expect(controller.isOpen, isFalse);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isTrue);
|
||||
|
||||
// Close the menu.
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.isOpen, isFalse);
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('[Default] MenuController can be moved to a different menu', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
controller: MenuController(),
|
||||
child: Menu(
|
||||
controller: controller,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isTrue);
|
||||
|
||||
// Swap the controllers.
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
RawMenuAnchorGroup(
|
||||
controller: MenuController(),
|
||||
child: Menu(
|
||||
key: UniqueKey(),
|
||||
controller: controller,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
expect(controller.isOpen, isFalse);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(controller.isOpen, isTrue);
|
||||
|
||||
// Close the menu.
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.isOpen, isFalse);
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('MenuController.maybeOf does not notify dependents when MenuController changes', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -700,7 +864,7 @@ void main() {
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/flutter/flutter/issues/156572.
|
||||
testWidgets('Unattached MenuController does not throw when calling close', (
|
||||
testWidgets('Detached MenuController does not throw when calling close', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final MenuController controller = MenuController();
|
||||
@ -709,7 +873,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Unattached MenuController returns false when calling isOpen', (
|
||||
testWidgets('Detached MenuController returns false when calling isOpen', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final MenuController controller = MenuController();
|
||||
@ -1323,7 +1487,7 @@ void main() {
|
||||
expect(insideTap, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Menus close and consume tap when consumesOutsideTap is true', (
|
||||
testWidgets('[Default] Menus close and consume tap when consumesOutsideTap is true', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
@ -1390,12 +1554,12 @@ void main() {
|
||||
await tester.tap(find.text(Tag.outside.text));
|
||||
await tester.pump();
|
||||
|
||||
// When the menu is open, outside taps are consumed. As a result, tapping
|
||||
// outside the menu will close it and not select the outside button.
|
||||
expect(selected, isEmpty);
|
||||
expect(opened, isEmpty);
|
||||
expect(closed, equals(<NestedTag>[Tag.a, Tag.anchor]));
|
||||
expect(selected, isEmpty);
|
||||
|
||||
// When the menu is open, don't expect the outside button to be selected.
|
||||
expect(selected, isEmpty);
|
||||
selected.clear();
|
||||
opened.clear();
|
||||
closed.clear();
|
||||
@ -1629,7 +1793,7 @@ void main() {
|
||||
// Menu implementations differ as to whether tabbing traverses a closes a
|
||||
// menu or traverses its items. By default, we let the user choose whether
|
||||
// to close the menu or traverse its items.
|
||||
testWidgets('Tab traversal is not handled.', (WidgetTester tester) async {
|
||||
testWidgets('Tab traversal is not handled', (WidgetTester tester) async {
|
||||
final FocusNode bFocusNode = FocusNode(debugLabel: Tag.b.focusNode);
|
||||
final FocusNode bbFocusNode = FocusNode(debugLabel: Tag.b.b.focusNode);
|
||||
addTearDown(bFocusNode.dispose);
|
||||
@ -1730,8 +1894,6 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('Menu closes on view size change', (WidgetTester tester) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
addTearDown(scrollController.dispose);
|
||||
final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view);
|
||||
|
||||
bool opened = false;
|
||||
@ -1741,25 +1903,18 @@ void main() {
|
||||
return MediaQuery(
|
||||
data: mediaQueryData.copyWith(size: size),
|
||||
child: App(
|
||||
SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Container(
|
||||
height: 1000,
|
||||
alignment: Alignment.center,
|
||||
child: Menu(
|
||||
onOpen: () {
|
||||
opened = true;
|
||||
closed = false;
|
||||
},
|
||||
onClose: () {
|
||||
opened = false;
|
||||
closed = true;
|
||||
},
|
||||
controller: controller,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
Menu(
|
||||
onOpen: () {
|
||||
opened = true;
|
||||
closed = false;
|
||||
},
|
||||
onClose: () {
|
||||
opened = false;
|
||||
closed = true;
|
||||
},
|
||||
controller: controller,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -2122,6 +2277,602 @@ void main() {
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
group('onOpenRequested', () {
|
||||
testWidgets('Triggered by MenuController.open', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
int onOpenCalled = 0;
|
||||
int onOpenRequestedCalled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onOpen: () {
|
||||
onOpenCalled += 1;
|
||||
},
|
||||
onOpenRequested: (ui.Offset? position, VoidCallback showMenu) {
|
||||
onOpenRequestedCalled += 1;
|
||||
},
|
||||
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onOpenCalled, equals(0));
|
||||
expect(onOpenRequestedCalled, equals(0));
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(onOpenCalled, equals(0));
|
||||
expect(onOpenRequestedCalled, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('Is passed a position', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
ui.Offset? menuPosition;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
|
||||
menuPosition = position;
|
||||
},
|
||||
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(menuPosition, isNull);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(menuPosition, isNull);
|
||||
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(menuPosition, isNull);
|
||||
|
||||
controller.open(position: const Offset(10, 15));
|
||||
await tester.pump();
|
||||
|
||||
expect(menuPosition, equals(const Offset(10, 15)));
|
||||
});
|
||||
|
||||
testWidgets('showOverlay triggers onOpen', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
int onOpenCalled = 0;
|
||||
int onOpenRequestedCalled = 0;
|
||||
VoidCallback? showMenuOverlay;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onOpen: () {
|
||||
onOpenCalled += 1;
|
||||
},
|
||||
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
|
||||
onOpenRequestedCalled += 1;
|
||||
showMenuOverlay = showOverlay;
|
||||
},
|
||||
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onOpenRequestedCalled, equals(0));
|
||||
expect(onOpenCalled, equals(0));
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(onOpenRequestedCalled, equals(1));
|
||||
expect(onOpenCalled, equals(0));
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
// Calling open again will trigger onOpenRequested again.
|
||||
expect(onOpenRequestedCalled, equals(2));
|
||||
expect(onOpenCalled, equals(0));
|
||||
|
||||
showMenuOverlay!();
|
||||
await tester.pump();
|
||||
|
||||
expect(onOpenRequestedCalled, equals(2));
|
||||
expect(onOpenCalled, equals(1));
|
||||
|
||||
// Calling showOverlay again will trigger onOpen again.
|
||||
showMenuOverlay!();
|
||||
await tester.pump();
|
||||
|
||||
expect(onOpenRequestedCalled, equals(2));
|
||||
expect(onOpenCalled, equals(2));
|
||||
});
|
||||
|
||||
testWidgets('showOverlay shows the menu at a position', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
|
||||
Timer(const Duration(milliseconds: 100), () {
|
||||
showOverlay();
|
||||
});
|
||||
},
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return Positioned(
|
||||
top: info.position?.dy ?? 0,
|
||||
left: info.position?.dx ?? 0,
|
||||
child: SizedBox.square(key: Tag.a.key, dimension: 300),
|
||||
);
|
||||
},
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(Tag.a.key), findsNothing);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(Tag.a.key), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 101));
|
||||
|
||||
expect(find.byKey(Tag.a.key), findsOneWidget);
|
||||
expect(tester.getTopLeft(find.byKey(Tag.a.key)), Offset.zero);
|
||||
|
||||
controller.open(position: const Offset(50, 50));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 101));
|
||||
|
||||
expect(find.byKey(Tag.a.key), findsOneWidget);
|
||||
expect(tester.getTopLeft(find.byKey(Tag.a.key)), const Offset(50, 50));
|
||||
});
|
||||
|
||||
testWidgets('showOverlay does nothing after the menu is disposed', (WidgetTester tester) async {
|
||||
VoidCallback? showMenuOverlay;
|
||||
int onOpenCalled = 0;
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onOpen: () {
|
||||
onOpenCalled += 1;
|
||||
},
|
||||
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
|
||||
showMenuOverlay = showOverlay;
|
||||
},
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
|
||||
},
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
controller.open();
|
||||
showMenuOverlay!();
|
||||
await tester.pump();
|
||||
|
||||
expect(onOpenCalled, equals(1));
|
||||
expect(find.text('Overlay'), findsOneWidget);
|
||||
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(const App(SizedBox()));
|
||||
|
||||
showMenuOverlay!();
|
||||
|
||||
expect(onOpenCalled, equals(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('onCloseRequested', () {
|
||||
testWidgets('Triggered by MenuController.close', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
int onCloseRequestedCalled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
onCloseRequestedCalled += 1;
|
||||
},
|
||||
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onCloseRequestedCalled, equals(0));
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(onCloseRequestedCalled, equals(0));
|
||||
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(onCloseRequestedCalled, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('hideOverlay triggers onClose', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
int onCloseCalled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onClose: () {
|
||||
onCloseCalled += 1;
|
||||
},
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
Timer(const Duration(milliseconds: 100), hideOverlay);
|
||||
},
|
||||
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
|
||||
expect(onCloseCalled, equals(0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 99));
|
||||
|
||||
expect(onCloseCalled, equals(0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(onCloseCalled, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('hideOverlay hides the menu overlay', (WidgetTester tester) async {
|
||||
final MenuController controller = MenuController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
Timer(const Duration(milliseconds: 100), hideOverlay);
|
||||
},
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
|
||||
},
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Overlay'), findsNothing);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Overlay'), findsOneWidget);
|
||||
|
||||
controller.close();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 99));
|
||||
|
||||
expect(find.text('Overlay'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(find.text('Overlay'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hideOverlay does nothing when called after disposal', (WidgetTester tester) async {
|
||||
VoidCallback? hideMenuOverlay;
|
||||
int onCloseCalled = 0;
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
controller: controller,
|
||||
onClose: () {
|
||||
onCloseCalled += 1;
|
||||
},
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
hideMenuOverlay = hideOverlay;
|
||||
},
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
|
||||
},
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Overlay'), findsOneWidget);
|
||||
|
||||
controller.close();
|
||||
hideMenuOverlay!();
|
||||
await tester.pump();
|
||||
|
||||
expect(onCloseCalled, equals(1));
|
||||
expect(find.text('Overlay'), findsNothing);
|
||||
|
||||
controller.open();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Overlay'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const App(SizedBox()));
|
||||
|
||||
hideMenuOverlay!();
|
||||
|
||||
expect(onCloseCalled, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('DismissMenuAction triggers onCloseRequested', (WidgetTester tester) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
int topCloseRequests = 0;
|
||||
int middleCloseRequests = 0;
|
||||
int bottomCloseRequests = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
topCloseRequests += 1;
|
||||
Timer(const Duration(milliseconds: 100), hideOverlay);
|
||||
},
|
||||
menuPanel: Panel(
|
||||
children: <Widget>[
|
||||
Menu(
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
middleCloseRequests += 1;
|
||||
},
|
||||
menuPanel: Panel(
|
||||
children: <Widget>[
|
||||
Menu(
|
||||
controller: controller,
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
bottomCloseRequests += 1;
|
||||
Timer(const Duration(milliseconds: 10), hideOverlay);
|
||||
},
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.a.a.text)]),
|
||||
child: AnchorButton(Tag.a.a, focusNode: focusNode),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const AnchorButton(Tag.a),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(Tag.a.text));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(Tag.a.a.text));
|
||||
await tester.pump();
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.a.a.text), findsOneWidget);
|
||||
|
||||
const ActionDispatcher().invokeAction(
|
||||
DismissMenuAction(controller: controller),
|
||||
const DismissIntent(),
|
||||
focusNode.context,
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(topCloseRequests, equals(1));
|
||||
expect(middleCloseRequests, equals(1));
|
||||
expect(bottomCloseRequests, equals(1));
|
||||
expect(find.text(Tag.a.a.a.text), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 11));
|
||||
|
||||
expect(topCloseRequests, equals(1));
|
||||
expect(middleCloseRequests, equals(1));
|
||||
expect(bottomCloseRequests, equals(1));
|
||||
expect(find.text(Tag.a.a.a.text), findsNothing);
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 88));
|
||||
|
||||
expect(topCloseRequests, equals(1));
|
||||
expect(middleCloseRequests, equals(1));
|
||||
expect(bottomCloseRequests, equals(1));
|
||||
expect(find.text(Tag.a.a.a.text), findsNothing);
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(topCloseRequests, equals(1));
|
||||
// Middle menu closes again in response to the outer menu calling
|
||||
// "hideOverlay"
|
||||
expect(middleCloseRequests, equals(2));
|
||||
expect(bottomCloseRequests, equals(1));
|
||||
expect(find.text(Tag.a.a.a.text), findsNothing);
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Outside tap triggers onCloseRequested', (WidgetTester tester) async {
|
||||
int closeRequests = 0;
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
Menu(
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
closeRequests += 1;
|
||||
hideOverlay();
|
||||
},
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return Positioned(
|
||||
left: info.anchorRect.left,
|
||||
top: info.anchorRect.bottom,
|
||||
child: TapRegion(
|
||||
groupId: info.tapRegionGroupId,
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
},
|
||||
child: Text(Tag.a.text),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(closeRequests, equals(0));
|
||||
|
||||
// Tap outside the menu to trigger the close request.
|
||||
await tester.tapAt(Offset.zero);
|
||||
await tester.pump();
|
||||
|
||||
expect(closeRequests, equals(1));
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('View size change triggers onCloseRequested on open menu', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view);
|
||||
|
||||
int onCloseRequestedCalled = 0;
|
||||
|
||||
Widget build(Size size) {
|
||||
return MediaQuery(
|
||||
data: mediaQueryData.copyWith(size: size),
|
||||
child: App(
|
||||
Menu(
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
onCloseRequestedCalled += 1;
|
||||
hideOverlay();
|
||||
},
|
||||
controller: controller,
|
||||
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(build(mediaQueryData.size));
|
||||
await tester.pump();
|
||||
|
||||
// Test menu resize before first open.
|
||||
await tester.pumpWidget(build(const Size(250, 250)));
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(onCloseRequestedCalled, equals(0));
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(build(const Size(200, 200)));
|
||||
|
||||
expect(onCloseRequestedCalled, equals(1));
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.isOpen, isFalse);
|
||||
|
||||
// Test menu resize after close.
|
||||
await tester.pumpWidget(build(const Size(300, 300)));
|
||||
|
||||
expect(onCloseRequestedCalled, equals(1));
|
||||
});
|
||||
|
||||
testWidgets('Ancestor scroll triggers onCloseRequested on open menu', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
addTearDown(scrollController.dispose);
|
||||
|
||||
int onCloseRequestedCalled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
App(
|
||||
SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: SizedBox(
|
||||
height: 1000,
|
||||
child: Menu(
|
||||
onCloseRequested: (VoidCallback hideOverlay) {
|
||||
onCloseRequestedCalled += 1;
|
||||
hideOverlay();
|
||||
},
|
||||
menuPanel: Panel(
|
||||
children: <Widget>[
|
||||
Button.tag(Tag.a),
|
||||
Button.tag(Tag.b),
|
||||
Button.tag(Tag.c),
|
||||
Button.tag(Tag.d),
|
||||
],
|
||||
),
|
||||
child: const AnchorButton(Tag.anchor),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
scrollController.jumpTo(10);
|
||||
await tester.pump();
|
||||
|
||||
// The menu is not open, so onCloseRequested should not be called.
|
||||
expect(onCloseRequestedCalled, equals(0));
|
||||
|
||||
await tester.tap(find.text(Tag.anchor.text));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.a.text), findsOneWidget);
|
||||
expect(onCloseRequestedCalled, equals(0));
|
||||
|
||||
scrollController.jumpTo(500);
|
||||
await tester.pump();
|
||||
|
||||
// Make sure we didn't just scroll the menu off-screen.
|
||||
expect(find.text(Tag.anchor.text), findsOneWidget);
|
||||
expect(onCloseRequestedCalled, equals(1));
|
||||
expect(find.text(Tag.a.text), findsNothing);
|
||||
|
||||
scrollController.jumpTo(5);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(Tag.anchor.text), findsOneWidget);
|
||||
expect(onCloseRequestedCalled, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ********* UTILITIES ********* //
|
||||
@ -2297,6 +3048,8 @@ class _ButtonState extends State<Button> {
|
||||
@override
|
||||
void dispose() {
|
||||
_internalFocusNode?.dispose();
|
||||
_states.dispose();
|
||||
_internalFocusNode = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -2446,7 +3199,7 @@ class Panel extends StatelessWidget {
|
||||
class Menu extends StatefulWidget {
|
||||
const Menu({
|
||||
super.key,
|
||||
required this.menuPanel,
|
||||
this.menuPanel,
|
||||
this.controller,
|
||||
this.child,
|
||||
this.builder,
|
||||
@ -2455,14 +3208,29 @@ class Menu extends StatefulWidget {
|
||||
this.onClose,
|
||||
this.useRootOverlay = false,
|
||||
this.consumeOutsideTaps = false,
|
||||
this.overlayBuilder,
|
||||
this.onOpenRequested = _defaultOnOpenRequested,
|
||||
this.onCloseRequested = _defaultOnCloseRequested,
|
||||
});
|
||||
final Widget menuPanel;
|
||||
|
||||
static void _defaultOnOpenRequested(Offset? position, VoidCallback showOverlay) {
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
static void _defaultOnCloseRequested(VoidCallback hideOverlay) {
|
||||
hideOverlay();
|
||||
}
|
||||
|
||||
final Widget? menuPanel;
|
||||
final Widget? child;
|
||||
final bool useRootOverlay;
|
||||
final VoidCallback? onOpen;
|
||||
final VoidCallback? onClose;
|
||||
final FocusNode? focusNode;
|
||||
final RawMenuAnchorChildBuilder? builder;
|
||||
final RawMenuAnchorOverlayBuilder? overlayBuilder;
|
||||
final RawMenuAnchorOpenRequestedCallback onOpenRequested;
|
||||
final RawMenuAnchorCloseRequestedCallback onCloseRequested;
|
||||
final MenuController? controller;
|
||||
final bool consumeOutsideTaps;
|
||||
|
||||
@ -2479,16 +3247,20 @@ class _MenuState extends State<Menu> {
|
||||
controller: widget.controller ?? (_controller ??= MenuController()),
|
||||
onOpen: widget.onOpen,
|
||||
onClose: widget.onClose,
|
||||
onOpenRequested: widget.onOpenRequested,
|
||||
onCloseRequested: widget.onCloseRequested,
|
||||
consumeOutsideTaps: widget.consumeOutsideTaps,
|
||||
useRootOverlay: widget.useRootOverlay,
|
||||
builder: widget.builder,
|
||||
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
|
||||
return Positioned(
|
||||
top: info.anchorRect.bottom,
|
||||
left: info.anchorRect.left,
|
||||
child: widget.menuPanel,
|
||||
);
|
||||
},
|
||||
overlayBuilder:
|
||||
widget.overlayBuilder ??
|
||||
(BuildContext context, RawMenuOverlayInfo info) {
|
||||
return Positioned(
|
||||
top: info.anchorRect.bottom,
|
||||
left: info.anchorRect.left,
|
||||
child: widget.menuPanel!,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user