From dfadc914331da6542c0891fa2d222eae006c3c42 Mon Sep 17 00:00:00 2001
From: davidhicks980 <59215665+davidhicks980@users.noreply.github.com>
Date: Tue, 24 Jun 2025 13:46:50 -0400
Subject: [PATCH] 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.
### 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 createState() => _MenuState();
}
class _MenuState extends State 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].
[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
---
.../raw_menu_anchor/raw_menu_anchor.2.dart | 160 ++++
.../raw_menu_anchor/raw_menu_anchor.3.dart | 245 +++++
.../raw_menu_anchor.2_test.dart | 99 ++
.../raw_menu_anchor.3_test.dart | 151 ++++
.../lib/src/widgets/raw_menu_anchor.dart | 375 ++++++--
.../test/widgets/raw_menu_anchor_test.dart | 846 +++++++++++++++++-
6 files changed, 1761 insertions(+), 115 deletions(-)
create mode 100644 examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart
create mode 100644 examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart
create mode 100644 examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.2_test.dart
create mode 100644 examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.3_test.dart
diff --git a/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart
new file mode 100644
index 00000000000..7942c441f41
--- /dev/null
+++ b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart
@@ -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 createState() => _RawMenuAnchorAnimationExampleState();
+}
+
+class _RawMenuAnchorAnimationExampleState extends State
+ 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.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())),
+ );
+ }
+}
diff --git a/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart
new file mode 100644
index 00000000000..2eb94441beb
--- /dev/null
+++ b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart
@@ -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: [
+ 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 createState() => MenuState();
+}
+
+class MenuState extends State 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())),
+ );
+ }
+}
diff --git a/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.2_test.dart b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.2_test.dart
new file mode 100644
index 00000000000..d87e374531f
--- /dev/null
+++ b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.2_test.dart
@@ -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);
+ });
+}
diff --git a/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.3_test.dart b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.3_test.dart
new file mode 100644
index 00000000000..637121eb31a
--- /dev/null
+++ b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.3_test.dart
@@ -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 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);
+ });
+}
diff --git a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart
index 8ebcd5f6c7e..4f1b216e32a 100644
--- a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart
+++ b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart
@@ -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 createState() => _RawMenuAnchorState();
@@ -336,15 +496,18 @@ mixin _RawMenuAnchorBaseMixin on State {
_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 on State {
// 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 on State {
/// 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 on State {
@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 on State {
}
class _RawMenuAnchorState extends State with _RawMenuAnchorBaseMixin {
- // 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 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 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 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 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 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
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
}
}
-/// 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
/// [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
diff --git a/packages/flutter/test/widgets/raw_menu_anchor_test.dart b/packages/flutter/test/widgets/raw_menu_anchor_test.dart
index bfbf199769c..c06dc7ae151 100644
--- a/packages/flutter/test/widgets/raw_menu_anchor_test.dart
+++ b/packages/flutter/test/widgets/raw_menu_anchor_test.dart
@@ -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: [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: [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: [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: [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: [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: [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([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: [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: [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: [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: [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: [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: [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: [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: [
+ Menu(
+ onCloseRequested: (VoidCallback hideOverlay) {
+ middleCloseRequests += 1;
+ },
+ menuPanel: Panel(
+ children: [
+ Menu(
+ controller: controller,
+ onCloseRequested: (VoidCallback hideOverlay) {
+ bottomCloseRequests += 1;
+ Timer(const Duration(milliseconds: 10), hideOverlay);
+ },
+ menuPanel: Panel(children: [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: [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: [
+ 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 {
@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 {
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,
);
}