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