Add RawMenuAnchor animation callbacks (#167806)

Alternative to https://github.com/flutter/flutter/pull/163481,
https://github.com/flutter/flutter/pull/167537,
https://github.com/flutter/flutter/pull/163481 that uses callbacks.

@dkwingsmt - you inspired me to simplify the menu behavior. I didn't end
up using Actions, mainly because nested behavior was unwieldy and
capturing BuildContext has drawbacks. This uses a basic callback
mechanism to animate the menu open and closed. Check out the examples.

<hr />

### The problem
RawMenuAnchor synchronously shows or hides an overlay menu in response
to `MenuController.open()` and `MenuController.close`, respectively.
Because animations cannot be run on a hidden overlay, there currently is
no way for developers to add animations to RawMenuAnchor and its
subclasses (MenuAnchor, DropdownMenuButton, etc).

### The solution
This PR:
- Adds two callbacks -- `onOpenRequested` and `onCloseRequested` -- to
RawMenuAnchor.
- onOpenRequested is called with a position and a showOverlay callback,
which opens the menu when called.
- onCloseRequested is called with a hideOverlay callback, which hides
the menu when called.

When `MenuController.open()` and `MenuController.close()` are called,
onOpenRequested and onCloseRequested are invoked, respectively.

Precursor for https://github.com/flutter/flutter/pull/143416,
https://github.com/flutter/flutter/issues/135025,
https://github.com/flutter/flutter/pull/143712

## Demo


https://github.com/user-attachments/assets/bb14abca-af26-45fe-8d45-289b5d07dab2

```dart
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: public_member_api_docs

import 'dart:ui' as ui;

import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor, RawMenuOverlayInfo;

import 'raw_menu_anchor.dart';

/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
  runApp(const App());
}

class Menu extends StatefulWidget {
  const Menu({super.key});

  @override
  State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  final MenuController menuController = MenuController();

  @override
  void initState() {
    super.initState();
    animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

  @override
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) {
    // Mount or reposition the menu before animating the menu open.
    showOverlay(position: position);

    if (animationController.isForwardOrCompleted) {
      // If the menu is already open or opening, the animation is already
      // running forward.
      return;
    }

    // Animate the menu into view. This will cancel the closing animation.
    animationController.forward();
  }

  void _handleMenuCloseRequest(VoidCallback hideOverlay) {
    if (!animationController.isForwardOrCompleted) {
      // If the menu is already closed or closing, do nothing.
      return;
    }

    // Animate the menu out of view.
    //
    // Be sure to use `whenComplete` so that the closing animation
    // can be interrupted by an opening animation.
    animationController.reverse().whenComplete(() {
      if (mounted) {
        // Hide the menu after the menu has closed
        hideOverlay();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuController,
      onOpenRequested: _handleMenuOpenRequest,
      onCloseRequested: _handleMenuCloseRequest,
      overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
        final ui.Offset position = info.anchorRect.bottomLeft;
        return Positioned(
          top: position.dy + 5,
          left: position.dx,
          child: TapRegion(
            groupId: info.tapRegionGroupId,
            child: Material(
              color: ColorScheme.of(context).primaryContainer,
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
              elevation: 3,
              child: SizeTransition(
                sizeFactor: animationController,
                child: const SizedBox(
                  height: 200,
                  width: 150,
                  child: Center(child: Text('Howdy', textAlign: TextAlign.center)),
                ),
              ),
            ),
          ),
        );
      },
      builder: (BuildContext context, MenuController menuController, Widget? child) {
        return FilledButton(
          onPressed: () {
            if (animationController.isForwardOrCompleted) {
              menuController.close();
            } else {
              menuController.open();
            }
          },
          child: const Text('Toggle Menu'),
        );
      },
    );
  }
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)),
      home: const Scaffold(body: Center(child: Menu())),
    );
  }
}

```

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
This commit is contained in:
davidhicks980 2025-06-24 13:46:50 -04:00 committed by GitHub
parent 12e4a898dc
commit dfadc91433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1761 additions and 115 deletions

View File

@ -0,0 +1,160 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: public_member_api_docs
import 'dart:ui';
import 'package:flutter/material.dart';
/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
runApp(const RawMenuAnchorAnimationApp());
}
class RawMenuAnchorAnimationExample extends StatefulWidget {
const RawMenuAnchorAnimationExample({super.key});
@override
State<RawMenuAnchorAnimationExample> createState() => _RawMenuAnchorAnimationExampleState();
}
class _RawMenuAnchorAnimationExampleState extends State<RawMenuAnchorAnimationExample>
with SingleTickerProviderStateMixin {
late final AnimationController animationController;
final MenuController menuController = MenuController();
AnimationStatus get _animationStatus => animationController.status;
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addStatusListener((AnimationStatus status) {
setState(() {
// Rebuild to reflect animation status changes on the UI.
});
});
}
@override
void dispose() {
animationController.dispose();
super.dispose();
}
void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
// Mount or reposition the menu before animating the menu open.
showOverlay();
if (_animationStatus.isForwardOrCompleted) {
// If the menu is already open or opening, the animation is already
// running forward.
return;
}
// Animate the menu into view.
animationController.forward();
}
void _handleMenuCloseRequest(VoidCallback hideOverlay) {
if (!_animationStatus.isForwardOrCompleted) {
// If the menu is already closed or closing, do nothing.
return;
}
// Animate the menu out of view.
animationController.reverse().whenComplete(hideOverlay);
}
@override
Widget build(BuildContext context) {
return RawMenuAnchor(
controller: menuController,
onOpenRequested: _handleMenuOpenRequest,
onCloseRequested: _handleMenuCloseRequest,
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
// Center the menu below the anchor.
final Offset position = info.anchorRect.bottomCenter.translate(-75, 4);
final ColorScheme colorScheme = ColorScheme.of(context);
return Positioned(
top: position.dy,
left: position.dx,
child: Semantics(
explicitChildNodes: true,
scopesRoute: true,
child: ExcludeFocus(
excluding: !_animationStatus.isForwardOrCompleted,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {
menuController.close();
},
child: ScaleTransition(
scale: animationController.view,
child: FadeTransition(
opacity: animationController.drive(
Animatable<double>.fromCallback((double value) => clampDouble(value, 0, 1)),
),
child: Material(
elevation: 8,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(16),
color: colorScheme.primary,
child: SizeTransition(
axisAlignment: -1,
sizeFactor: animationController.view,
fixedCrossAxisSizeFactor: 1.0,
child: SizedBox(
height: 200,
width: 150,
child: Text(
'ANIMATION STATUS:\n${_animationStatus.name}',
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.onPrimary, fontSize: 12),
),
),
),
),
),
),
),
),
),
);
},
builder: (BuildContext context, MenuController menuController, Widget? child) {
return FilledButton(
onPressed: () {
if (_animationStatus.isForwardOrCompleted) {
menuController.close();
} else {
menuController.open();
}
},
child: _animationStatus.isForwardOrCompleted ? const Text('Close') : const Text('Open'),
);
},
);
}
}
class RawMenuAnchorAnimationApp extends StatelessWidget {
const RawMenuAnchorAnimationApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
),
),
home: const Scaffold(body: Center(child: RawMenuAnchorAnimationExample())),
);
}
}

View File

@ -0,0 +1,245 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: public_member_api_docs
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
/// Flutter code sample for a [RawMenuAnchor] that animates a nested menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
runApp(const RawMenuAnchorSubmenuAnimationApp());
}
/// Signature for the function that builds a [Menu]'s contents.
///
/// The [animationStatus] parameter indicates the current state of the menu
/// animation, which can be used to adjust the appearance of the menu panel.
typedef MenuPanelBuilder = Widget Function(BuildContext context, AnimationStatus animationStatus);
/// Signature for the function that builds a [Menu]'s anchor button.
///
/// The [MenuController] can be used to open and close the menu.
///
/// The [animationStatus] indicates the current state of the menu animation,
/// which can be used to adjust the appearance of the menu panel.
typedef MenuButtonBuilder =
Widget Function(
BuildContext context,
MenuController controller,
AnimationStatus animationStatus,
);
class RawMenuAnchorSubmenuAnimationExample extends StatelessWidget {
const RawMenuAnchorSubmenuAnimationExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
panelBuilder: (BuildContext context, AnimationStatus animationStatus) {
final MenuController rootMenuController = MenuController.maybeOf(context)!;
return Align(
alignment: Alignment.topRight,
child: Column(
children: <Widget>[
for (int i = 0; i < 4; i++)
Menu(
panelBuilder: (BuildContext context, AnimationStatus status) {
return SizedBox(
height: 120,
width: 120,
child: Center(
child: Text('Panel $i:\n${status.name}', textAlign: TextAlign.center),
),
);
},
buttonBuilder: (
BuildContext context,
MenuController controller,
AnimationStatus animationStatus,
) {
return MenuItemButton(
onFocusChange: (bool focused) {
if (focused) {
rootMenuController.closeChildren();
controller.open();
}
},
onPressed: () {
if (!animationStatus.isForwardOrCompleted) {
rootMenuController.closeChildren();
controller.open();
} else {
controller.close();
}
},
trailingIcon: const Text(''),
child: Text('Submenu $i'),
);
},
),
],
),
);
},
buttonBuilder: (
BuildContext context,
MenuController controller,
AnimationStatus animationStatus,
) {
return FilledButton(
onPressed: () {
if (animationStatus.isForwardOrCompleted) {
controller.close();
} else {
controller.open();
}
},
child: const Text('Menu'),
);
},
);
}
}
class Menu extends StatefulWidget {
const Menu({super.key, required this.panelBuilder, required this.buttonBuilder});
final MenuPanelBuilder panelBuilder;
final MenuButtonBuilder buttonBuilder;
@override
State<Menu> createState() => MenuState();
}
class MenuState extends State<Menu> with SingleTickerProviderStateMixin {
final MenuController menuController = MenuController();
late final AnimationController animationController;
late final CurvedAnimation animation;
bool get isSubmenu => MenuController.maybeOf(context) != null;
AnimationStatus get animationStatus => animationController.status;
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addStatusListener((AnimationStatus status) {
if (mounted) {
setState(() {
// Rebuild to reflect animation status changes.
});
}
});
animation = CurvedAnimation(parent: animationController, curve: Curves.easeOutQuart);
}
@override
void dispose() {
animationController.dispose();
animation.dispose();
super.dispose();
}
void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
// Mount or reposition the menu before animating the menu open.
showOverlay();
if (animationStatus.isForwardOrCompleted) {
// If the menu is already open or opening, the animation is already
// running forward.
return;
}
// Animate the menu into view.
animationController.forward();
}
void _handleMenuCloseRequest(VoidCallback hideOverlay) {
if (!animationStatus.isForwardOrCompleted) {
// If the menu is already closed or closing, do nothing.
return;
}
// Animate the menu's children out of view.
menuController.closeChildren();
// Animate the menu out of view.
animationController.reverse().whenComplete(hideOverlay);
}
@override
Widget build(BuildContext context) {
return Semantics(
role: SemanticsRole.menu,
child: RawMenuAnchor(
controller: menuController,
onOpenRequested: _handleMenuOpenRequest,
onCloseRequested: _handleMenuCloseRequest,
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
final ui.Offset position =
isSubmenu ? info.anchorRect.topRight : info.anchorRect.bottomLeft;
final ColorScheme colorScheme = ColorScheme.of(context);
return Positioned(
top: position.dy,
left: position.dx,
child: Semantics(
explicitChildNodes: true,
scopesRoute: true,
// Remove focus while the menu is closing.
child: ExcludeFocus(
excluding: !animationStatus.isForwardOrCompleted,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {
menuController.close();
},
child: FadeTransition(
opacity: animation,
child: Material(
elevation: 8,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(8),
shadowColor: colorScheme.shadow,
child: SizeTransition(
axisAlignment: position.dx < 0 ? 1 : -1,
sizeFactor: animation,
fixedCrossAxisSizeFactor: 1.0,
child: widget.panelBuilder(context, animationStatus),
),
),
),
),
),
),
);
},
builder: (BuildContext context, MenuController controller, Widget? child) {
return widget.buttonBuilder(context, controller, animationStatus);
},
),
);
}
}
class RawMenuAnchorSubmenuAnimationApp extends StatelessWidget {
const RawMenuAnchorSubmenuAnimationApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
),
),
home: const Scaffold(body: Center(child: RawMenuAnchorSubmenuAnimationExample())),
);
}
}

View File

@ -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);
});
}

View File

@ -0,0 +1,151 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.3.dart' as example;
import 'package:flutter_test/flutter_test.dart';
String getPanelText(int i, AnimationStatus status) => 'Panel $i:\n${status.name}';
Future<TestGesture> hoverOver(WidgetTester tester, Offset location) async {
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(location);
return gesture;
}
void main() {
testWidgets('Root menu opens when anchor button is pressed', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
final Finder button = find.byType(FilledButton);
await tester.tap(button);
await tester.pump();
final Finder panel =
find
.ancestor(of: find.textContaining('Submenu 0'), matching: find.byType(ExcludeFocus))
.first;
expect(
tester.getRect(panel),
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 324.0), epsilon: 0.1),
);
await tester.pump(const Duration(milliseconds: 100));
expect(
tester.getRect(panel),
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 499.7), epsilon: 0.1),
);
await tester.pump(const Duration(milliseconds: 101));
expect(
tester.getRect(panel),
rectMoreOrLessEquals(const ui.Rect.fromLTRB(347.8, 324.0, 524.8, 516.0), epsilon: 0.1),
);
expect(find.textContaining('Submenu'), findsNWidgets(4));
});
testWidgets('Hover traversal opens one submenu at a time', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
// Open root menu.
await tester.tap(find.byType(FilledButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu 0').first;
await hoverOver(tester, tester.getCenter(menuItem));
await tester.pump();
await tester.pump(const Duration(milliseconds: 201));
for (int i = 1; i < 4; i++) {
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu $i').first;
await hoverOver(tester, tester.getCenter(menuItem));
await tester.pump();
expect(find.text(getPanelText(i - 1, AnimationStatus.reverse)), findsOneWidget);
expect(find.text(getPanelText(i, AnimationStatus.forward)), findsOneWidget);
await tester.pump(const Duration(milliseconds: 201));
expect(find.text(getPanelText(i - 1, AnimationStatus.dismissed)), findsNothing);
expect(find.text(getPanelText(i, AnimationStatus.completed)), findsOneWidget);
}
});
testWidgets('Submenu opens at expected rate', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
// Open root menu.
await tester.tap(find.byType(FilledButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 201));
// Start hovering over submenu item
final Finder menuItem = find.widgetWithText(MenuItemButton, 'Submenu 0').first;
await hoverOver(tester, tester.getCenter(menuItem));
await tester.pump();
final Finder panel =
find
.ancestor(of: find.textContaining('Panel 0'), matching: find.byType(ExcludeFocus))
.first;
// 25% through, 70% height
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getSize(panel).height, moreOrLessEquals(0.7 * 120, epsilon: 1));
// 50% through, 91.5% height
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getSize(panel).height, moreOrLessEquals(0.91 * 120, epsilon: 1));
// 100% through, full height
await tester.pump(const Duration(milliseconds: 101));
expect(tester.getSize(panel).height, moreOrLessEquals(120, epsilon: 1));
// Close submenu
final Finder menuItem1 = find.widgetWithText(MenuItemButton, 'Submenu 1').first;
await hoverOver(tester, tester.getCenter(menuItem1));
await tester.pump();
// 25% through, ~98% height
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getSize(panel).height, moreOrLessEquals(0.98 * 120, epsilon: 1));
// 50% through, ~91.5% height
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getSize(panel).height, moreOrLessEquals(0.91 * 120, epsilon: 1));
});
testWidgets('Outside tap closes all menus', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorSubmenuAnimationApp());
// Open root menu and submenu.
await tester.tap(find.byType(FilledButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 201));
await hoverOver(
tester,
tester.getCenter(find.widgetWithText(MenuItemButton, 'Submenu 0').first),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 201));
expect(find.textContaining(AnimationStatus.completed.name), findsOneWidget);
expect(find.textContaining(AnimationStatus.reverse.name), findsNothing);
// Tap outside
await tester.tapAt(const Offset(10, 10));
await tester.pump();
expect(find.textContaining(AnimationStatus.completed.name), findsNothing);
expect(find.textContaining(AnimationStatus.reverse.name), findsOneWidget);
});
}

View File

@ -117,7 +117,20 @@ typedef RawMenuAnchorOverlayBuilder =
typedef RawMenuAnchorChildBuilder =
Widget Function(BuildContext context, MenuController controller, Widget? child);
// An [InheritedWidget] used to notify anchor descendants when a menu opens
/// Signature for the callback used by [RawMenuAnchor.onOpenRequested] to
/// intercept requests to open a menu.
///
/// See [RawMenuAnchor.onOpenRequested] for more information.
typedef RawMenuAnchorOpenRequestedCallback =
void Function(Offset? position, VoidCallback showOverlay);
/// Signature for the callback used by [RawMenuAnchor.onCloseRequested] to
/// intercept requests to close a menu.
///
/// See [RawMenuAnchor.onCloseRequested] for more information.
typedef RawMenuAnchorCloseRequestedCallback = void Function(VoidCallback hideOverlay);
// An InheritedWidget used to notify anchor descendants when a menu opens
// and closes, and to pass the anchor's controller to descendants.
class _MenuControllerScope extends InheritedWidget {
const _MenuControllerScope({
@ -153,31 +166,70 @@ class _MenuControllerScope extends InheritedWidget {
/// If [MenuController.open] is called with a `position` argument, it will be
/// passed to the `info` argument of the `overlayBuilder` function.
///
/// Users are responsible for managing the positioning, semantics, and focus of
/// the menu.
/// The [RawMenuAnchor] does not manage semantics and focus of the menu.
///
/// To programmatically control a [RawMenuAnchor], like opening or closing it, or checking its state,
/// you can get its associated [MenuController]. Use `MenuController.maybeOf(BuildContext context)`
/// to retrieve the controller for the closest [RawMenuAnchor] ancestor of a given `BuildContext`.
/// More detailed usage of [MenuController] is available in its class documentation.
/// ### Adding animations to menus
///
/// A [RawMenuAnchor] has no knowledge of animations, as evident from its APIs,
/// which don't involve [AnimationController] at all. It only knows whether the
/// overlay is shown or hidden.
///
/// If another widget intends to implement a menu with opening and closing
/// transitions, [RawMenuAnchor]'s overlay should remain visible throughout both
/// the opening and closing animation durations.
///
/// This means that the `showOverlay` callback passed to [onOpenRequested]
/// should be called before the first frame of the opening animation.
/// Conversely, `hideOverlay` within [onCloseRequested] should only be called
/// after the closing animation has completed.
///
/// This also means that, if [MenuController.open] is called while the overlay
/// is already visible, [RawMenuAnchor] has no way of knowing whether the menu
/// is currently opening, closing, or stably displayed. The parent widget will
/// need to manage additional information (such as the state of an
/// [AnimationController]) to determine how to respond in such scenarios.
///
/// To programmatically control a [RawMenuAnchor], like opening or closing it,
/// or checking its state, you can get its associated [MenuController]. Use
/// `MenuController.maybeOf(BuildContext context)` to retrieve the controller
/// for the closest [RawMenuAnchor] ancestor of a given `BuildContext`. More
/// detailed usage of [MenuController] is available in its class documentation.
///
/// {@tool dartpad}
///
/// This example uses a [RawMenuAnchor] to build a basic select menu with
/// four items.
/// This example uses a [RawMenuAnchor] to build a basic select menu with four
/// items.
///
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
///
/// This example uses [RawMenuAnchor.onOpenRequested] and
/// [RawMenuAnchor.onCloseRequested] to build an animated menu.
///
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart **
/// {@end-tool}
///
/// {@tool dartpad}
///
/// This example uses [RawMenuAnchor.onOpenRequested] and
/// [RawMenuAnchor.onCloseRequested] to build an animated nested menu.
///
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart **
/// {@end-tool}
class RawMenuAnchor extends StatefulWidget {
/// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder].
///
/// The [overlayBuilder] should not be null.
/// The [overlayBuilder] must not be null.
const RawMenuAnchor({
super.key,
this.childFocusNode,
this.consumeOutsideTaps = false,
this.onOpen,
this.onClose,
this.onOpenRequested = _defaultOnOpenRequested,
this.onCloseRequested = _defaultOnCloseRequested,
this.useRootOverlay = false,
this.builder,
required this.controller,
@ -185,12 +237,110 @@ class RawMenuAnchor extends StatefulWidget {
this.child,
});
/// A callback that is invoked when the menu is opened.
/// Called when the menu overlay is shown.
///
/// When [MenuController.open] is called, [onOpenRequested] is invoked with a
/// `showOverlay` callback that, when called, shows the menu overlay and
/// triggers [onOpen].
///
/// The default implementation of [onOpenRequested] calls `showOverlay`
/// synchronously, thereby calling [onOpen] synchronously. In this case,
/// [onOpen] is called regardless of whether the menu overlay is already
/// showing.
///
/// Custom implementations of [onOpenRequested] can delay the call to
/// `showOverlay`, or not call it at all, in which case [onOpen] will not be
/// called. Calling `showOverlay` after disposal is a no-op, and will not
/// trigger [onOpen].
///
/// A typical usage is to respond when the menu first becomes interactive,
/// such as by setting focus to a menu item.
final VoidCallback? onOpen;
/// A callback that is invoked when the menu is closed.
/// Called when the menu overlay is hidden.
///
/// When [MenuController.close] is called, [onCloseRequested] is invoked with
/// a `hideOverlay` callback that, when called, hides the menu overlay and
/// triggers [onClose].
///
/// The default implementation of [onCloseRequested] calls `hideOverlay`
/// synchronously, thereby calling [onClose] synchronously. In this case,
/// [onClose] is called regardless of whether the menu overlay is already
/// hidden.
///
/// Custom implementations of [onCloseRequested] can delay the call to
/// `hideOverlay` or not call it at all, in which case [onClose] will not be
/// called. Calling `hideOverlay` after disposal is a no-op, and will not
/// trigger [onClose].
final VoidCallback? onClose;
/// Called when a request is made to open the menu.
///
/// This callback is triggered every time [MenuController.open] is called,
/// even when the menu overlay is already showing. As a result, this callback
/// is a good place to begin menu opening animations, or observe when a menu
/// is repositioned.
///
/// After an open request is intercepted, the `showOverlay` callback should be
/// called when the menu overlay (the widget built by [overlayBuilder]) is
/// ready to be shown. This can occur immediately (the default behavior), or
/// after a delay. Calling `showOverlay` sets [MenuController.isOpen] to true,
/// builds (or rebuilds) the overlay widget, and shows the menu overlay at the
/// front of the overlay stack.
///
/// If `showOverlay` is not called, the menu will stay hidden. Calling
/// `showOverlay` after disposal is a no-op, meaning it will not trigger
/// [onOpen] or show the menu overlay.
///
/// If a [RawMenuAnchor] is used in a themed menu that plays an opening
/// animation, the themed menu should show the overlay before starting the
/// opening animation, since the animation plays on the overlay itself.
///
/// The `position` argument is the `position` that [MenuController.open] was
/// called with.
///
/// A typical [onOpenRequested] consists of the following steps:
///
/// 1. Optional delay.
/// 2. Call `showOverlay` (whose call chain eventually invokes [onOpen]).
/// 3. Optionally start the opening animation.
///
/// Defaults to a callback that immediately shows the menu.
final RawMenuAnchorOpenRequestedCallback onOpenRequested;
/// Called when a request is made to close the menu.
///
/// This callback is triggered every time [MenuController.close] is called,
/// regardless of whether the overlay is already hidden. As a result, this
/// callback can be used to add a delay or a closing animation before the menu
/// is hidden.
///
/// If the menu is not closed, this callback will also be called when the root
/// menu anchor is scrolled and when the screen is resized.
///
/// After a close request is intercepted and closing behaviors have completed,
/// the `hideOverlay` callback should be called. This callback sets
/// [MenuController.isOpen] to false and hides the menu overlay widget. If the
/// [RawMenuAnchor] is used in a themed menu that plays a closing animation,
/// `hideOverlay` should be called after the closing animation has ended,
/// since the animation plays on the overlay itself. This means that
/// [MenuController.isOpen] will stay true while closing animations are
/// running.
///
/// Calling `hideOverlay` after disposal is a no-op, meaning it will not
/// trigger [onClose] or hide the menu overlay.
///
/// Typically, [onCloseRequested] consists of the following steps:
///
/// 1. Optionally start the closing animation and wait for it to complete.
/// 2. Call `hideOverlay` (whose call chain eventually invokes [onClose]).
///
/// Throughout the closing sequence, menus should typically not be focusable
/// or interactive.
///
/// Defaults to a callback that immediately hides the menu.
final RawMenuAnchorCloseRequestedCallback onCloseRequested;
/// A builder that builds the widget that this [RawMenuAnchor] surrounds.
///
/// Typically, this is a button used to open the menu by calling
@ -208,6 +358,8 @@ class RawMenuAnchor extends StatefulWidget {
/// to rebuild this child when those change.
final Widget? child;
/// Called to build and position the menu overlay.
///
/// The [overlayBuilder] function is passed a [RawMenuOverlayInfo] object that
/// defines the anchor's [Rect], the [Size] of the overlay, the
/// [TapRegion.groupId] for the menu system, and the position [Offset] passed
@ -249,8 +401,8 @@ class RawMenuAnchor extends StatefulWidget {
/// If not supplied, the anchor will not retain focus when the menu is opened.
final FocusNode? childFocusNode;
/// Whether or not a tap event that closes the menu will be permitted to
/// continue on to the gesture arena.
/// Whether a tap event that closes the menu will be permitted to continue on
/// to the gesture arena.
///
/// If false, then tapping outside of a menu when the menu is open will both
/// close the menu, and allow the tap to participate in the gesture arena.
@ -265,6 +417,14 @@ class RawMenuAnchor extends StatefulWidget {
/// widgets.
final MenuController controller;
static void _defaultOnOpenRequested(Offset? position, VoidCallback showOverlay) {
showOverlay();
}
static void _defaultOnCloseRequested(VoidCallback hideOverlay) {
hideOverlay();
}
@override
State<RawMenuAnchor> createState() => _RawMenuAnchorState();
@ -336,15 +496,18 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
_parent?._addChild(this);
}
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll);
_scrollPosition = Scrollable.maybeOf(context)?.position;
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll);
final Size newSize = MediaQuery.sizeOf(context);
if (_viewSize != null && newSize != _viewSize) {
// Close the menus if the view changes size.
root.close();
if (isRoot) {
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll);
_scrollPosition = Scrollable.maybeOf(context)?.position;
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll);
final Size newSize = MediaQuery.sizeOf(context);
if (_viewSize != null && newSize != _viewSize && isOpen) {
// Close the menus if the view changes size.
handleCloseRequest();
}
_viewSize = newSize;
}
_viewSize = newSize;
}
@override
@ -381,8 +544,8 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
// If an ancestor scrolls, and we're a root anchor, then close the menus.
// Don't just close it on *any* scroll, since we want to be able to scroll
// menus themselves if they're too big for the view.
if (isRoot) {
close();
if (isOpen) {
handleCloseRequest();
}
}
@ -403,22 +566,56 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
/// Open the menu, optionally at a position relative to the [RawMenuAnchor].
///
/// Call this when the menu should be shown to the user.
/// Call this when the menu overlay should be shown and added to the widget
/// tree.
///
/// The optional `position` argument should specify the location of the menu in
/// the local coordinates of the [RawMenuAnchor].
/// The optional `position` argument should specify the location of the menu
/// in the local coordinates of the [RawMenuAnchor].
@protected
void open({Offset? position});
/// Close the menu.
/// Close the menu and all of its children.
///
/// Call this when the menu overlay should be hidden and removed from the
/// widget tree.
///
/// If `inDispose` is true, this method call was triggered by the widget being
/// unmounted.
@protected
void close({bool inDispose = false});
/// Implemented by subclasses to define what to do when [MenuController.open]
/// is called.
///
/// This method should not be directly called by subclasses. Its call chain
/// should eventually invoke `_RawMenuAnchorBaseMixin.open`
@protected
void handleOpenRequest({Offset? position});
/// Implemented by subclasses to define what to do when [MenuController.close]
/// is called.
///
/// This method should not be directly called by subclasses. Its call chain
/// should eventually invoke `_RawMenuAnchorBaseMixin.close`.
@protected
void handleCloseRequest();
/// Request that the submenus of this menu be closed.
///
/// By default, this method will call [handleCloseRequest] on each child of this
/// menu, which will trigger the closing sequence of each child.
///
/// If `inDispose` is true, this method was triggered by the widget being
/// unmounted.
@protected
void closeChildren({bool inDispose = false}) {
assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}'));
for (final _RawMenuAnchorBaseMixin child in List<_RawMenuAnchorBaseMixin>.of(_anchorChildren)) {
child.close(inDispose: inDispose);
if (inDispose) {
child.close(inDispose: inDispose);
} else {
child.handleCloseRequest();
}
}
}
@ -428,7 +625,9 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
@protected
void handleOutsideTap(PointerDownEvent pointerDownEvent) {
assert(_debugMenuInfo('Tapped Outside $menuController'));
closeChildren();
if (isOpen) {
closeChildren();
}
}
// Used to build the anchor widget in subclasses.
@ -457,10 +656,7 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
}
class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMixin<RawMenuAnchor> {
// This is the global key that is used later to determine the bounding rect
// for the anchor's region that the CustomSingleChildLayout's delegate
// uses to determine where to place the menu on the screen and to avoid the
// view's edges.
// The global key used to determine the bounding rect for the anchor.
final GlobalKey _anchorKey = GlobalKey<_RawMenuAnchorState>(
debugLabel: kReleaseMode ? null : 'MenuAnchor',
);
@ -499,14 +695,11 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
@override
void open({Offset? position}) {
assert(menuController._anchor == this);
if (isOpen) {
if (position == _menuPosition) {
assert(_debugMenuInfo("Not opening $this because it's already open"));
// The menu is open and not being moved, so just return.
return;
}
if (!mounted) {
return;
}
if (isOpen) {
// The menu is already open, but we need to move to another location, so
// close it first.
close();
@ -517,9 +710,8 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
// Close all siblings.
_parent?.closeChildren();
assert(!_overlayController.isShowing);
_parent?._childChangedOpenState();
_menuPosition = position;
_parent?._childChangedOpenState();
_overlayController.show();
if (_isRootOverlayAnchor) {
@ -527,17 +719,11 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
}
widget.onOpen?.call();
if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty to notify MenuController dependents.
});
}
setState(() {
// Mark dirty to notify MenuController dependents.
});
}
// Close the menu.
//
// Call this when the menu should be closed. Has no effect if the menu is
// already closed.
@override
void close({bool inDispose = false}) {
assert(_debugMenuInfo('Closing $this'));
@ -569,6 +755,32 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
}
}
@override
void handleOpenRequest({ui.Offset? position}) {
widget.onOpenRequested(position, () {
open(position: position);
});
}
@override
void handleCloseRequest() {
// Changes in MediaQuery.sizeOf(context) cause RawMenuAnchor to close during
// didChangeDependencies. When this happens, calling setState during the
// closing sequence (handleCloseRequest -> onCloseRequested -> hideOverlay)
// will throw an error, since we'd be scheduling a build during a build. We
// avoid this by checking if we're in a build, and if so, we schedule the
// close for the next frame.
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
widget.onCloseRequested(close);
} else {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
widget.onCloseRequested(close);
}
}, debugLabel: 'RawMenuAnchor.handleCloseRequest');
}
}
Widget _buildOverlay(BuildContext context) {
final BuildContext anchorContext = _anchorKey.currentContext!;
final RenderBox overlay =
@ -641,7 +853,7 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
///
/// When a [MenuController] is given to a [RawMenuAnchorGroup],
/// - [MenuController.open] has no effect.
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open.
/// - [MenuController.isOpen] reflects whether any child [RawMenuAnchor] is
/// open.
///
@ -734,6 +946,18 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
return;
}
@override
void handleCloseRequest() {
assert(_debugMenuInfo('Requesting close $this'));
close();
}
@override
void handleOpenRequest({ui.Offset? position}) {
assert(_debugMenuInfo('Requesting open $this'));
open(position: position);
}
@override
Widget buildAnchor(BuildContext context) {
return TapRegion(
@ -744,8 +968,8 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
}
}
/// A controller used to manage a menu created by a [RawMenuAnchor], or
/// [RawMenuAnchorGroup].
/// A controller used to manage a menu created by a subclass of [RawMenuAnchor],
/// such as [MenuAnchor], [MenuBar], [SubmenuButton].
///
/// A [MenuController] is used to control and interrogate a menu after it has
/// been created, with methods such as [open] and [close], and state accessors
@ -756,26 +980,18 @@ class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
/// [MenuBar], [SubmenuButton], or [RawMenuAnchor]. Doing so will not establish
/// a dependency relationship.
///
/// [MenuController.maybeIsOpenOf] can be used to interrogate the state of a
/// menu from the [BuildContext] of a widget that is a descendant of a
/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a
/// dependency relationship, so the calling widget will rebuild when the menu
/// opens and closes, and when the [MenuController] changes.
///
/// See also:
///
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines.
/// * [MenuBar], a widget that creates a menu bar that can take an optional
/// [MenuController].
/// * [SubmenuButton], a Material widget that has a button that manages a
/// submenu.
/// * [RawMenuAnchor], a generic widget that manages a submenu.
/// * [RawMenuAnchorGroup], a generic widget that wraps a group of submenus.
class MenuController {
/// The anchor that this controller controls.
///
/// This is set automatically when a [MenuController] is given to the anchor
/// it controls.
/// * [SubmenuButton], a widget that has a button that manages a submenu.
/// * [RawMenuAnchor], a widget that defines a region that has submenu.
final class MenuController {
// The anchor that this controller controls.
//
// This is set automatically when this `MenuController` is attached to an
// anchor.
_RawMenuAnchorBaseMixin? _anchor;
/// Whether or not the menu associated with this [MenuController] is open.
@ -784,13 +1000,17 @@ class MenuController {
/// Opens the menu that this [MenuController] is associated with.
///
/// If `position` is given, then the menu will open at the position given, in
/// the coordinate space of the root overlay.
/// the coordinate space of the [RawMenuAnchor] that this controller is
/// attached to.
///
/// If given, the `position` will override the [MenuAnchor.alignmentOffset]
/// given to the [MenuAnchor].
///
/// If the menu's anchor point is scrolled by an ancestor, or the view changes
/// size, then any open menus will automatically close.
/// size, then any open menu will automatically close.
void open({Offset? position}) {
assert(_anchor != null);
_anchor!.open(position: position);
_anchor!.handleOpenRequest(position: position);
}
/// Close the menu that this [MenuController] is associated with.
@ -801,7 +1021,7 @@ class MenuController {
/// If the menu's anchor point is scrolled by an ancestor, or the view changes
/// size, then any open menu will automatically close.
void close() {
_anchor?.close();
_anchor?.handleCloseRequest();
}
/// Close the children of the menu associated with this [MenuController],
@ -822,9 +1042,8 @@ class MenuController {
}
}
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] or
/// [RawMenuAnchorGroup] nearest to the given `context`, if one exists.
/// Otherwise, returns null.
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] nearest to
/// the given `context`, if one exists. Otherwise, returns null.
///
/// This method will not establish a dependency relationship, so the calling
/// widget will not rebuild when the menu opens and closes, nor when the
@ -867,7 +1086,7 @@ class DismissMenuAction extends DismissAction {
@override
void invoke(DismissIntent intent) {
controller._anchor!.root.close();
controller._anchor!.root.handleCloseRequest();
}
@override

View File

@ -612,6 +612,170 @@ void main() {
expect(overlayBuilds, equals(1));
});
testWidgets('MenuController can be changed', (WidgetTester tester) async {
final MenuController controller = MenuController();
final MenuController groupController = MenuController();
final MenuController newController = MenuController();
final MenuController newGroupController = MenuController();
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: controller,
child: Menu(
controller: groupController,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
expect(groupController.isOpen, isTrue);
expect(newController.isOpen, isFalse);
expect(newGroupController.isOpen, isFalse);
// Swap the controllers.
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: newController,
child: Menu(
controller: newGroupController,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isFalse);
expect(groupController.isOpen, isFalse);
expect(newController.isOpen, isTrue);
expect(newGroupController.isOpen, isTrue);
// Close the new controller.
newController.close();
await tester.pump();
expect(newController.isOpen, isFalse);
expect(newGroupController.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('[Group] MenuController can be moved to a different menu', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: controller,
child: Menu(
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
// Swap the controllers.
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
key: UniqueKey(),
controller: controller,
child: Menu(
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
expect(find.text(Tag.a.text), findsNothing);
expect(controller.isOpen, isFalse);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
// Close the menu.
controller.close();
await tester.pump();
expect(controller.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('[Default] MenuController can be moved to a different menu', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: MenuController(),
child: Menu(
controller: controller,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
// Swap the controllers.
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: MenuController(),
child: Menu(
key: UniqueKey(),
controller: controller,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
),
);
expect(find.text(Tag.a.text), findsNothing);
expect(controller.isOpen, isFalse);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
// Close the menu.
controller.close();
await tester.pump();
expect(controller.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('MenuController.maybeOf does not notify dependents when MenuController changes', (
WidgetTester tester,
) async {
@ -700,7 +864,7 @@ void main() {
});
// Regression test for https://github.com/flutter/flutter/issues/156572.
testWidgets('Unattached MenuController does not throw when calling close', (
testWidgets('Detached MenuController does not throw when calling close', (
WidgetTester tester,
) async {
final MenuController controller = MenuController();
@ -709,7 +873,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('Unattached MenuController returns false when calling isOpen', (
testWidgets('Detached MenuController returns false when calling isOpen', (
WidgetTester tester,
) async {
final MenuController controller = MenuController();
@ -1323,7 +1487,7 @@ void main() {
expect(insideTap, isFalse);
});
testWidgets('Menus close and consume tap when consumesOutsideTap is true', (
testWidgets('[Default] Menus close and consume tap when consumesOutsideTap is true', (
WidgetTester tester,
) async {
await tester.pumpWidget(
@ -1390,12 +1554,12 @@ void main() {
await tester.tap(find.text(Tag.outside.text));
await tester.pump();
// When the menu is open, outside taps are consumed. As a result, tapping
// outside the menu will close it and not select the outside button.
expect(selected, isEmpty);
expect(opened, isEmpty);
expect(closed, equals(<NestedTag>[Tag.a, Tag.anchor]));
expect(selected, isEmpty);
// When the menu is open, don't expect the outside button to be selected.
expect(selected, isEmpty);
selected.clear();
opened.clear();
closed.clear();
@ -1629,7 +1793,7 @@ void main() {
// Menu implementations differ as to whether tabbing traverses a closes a
// menu or traverses its items. By default, we let the user choose whether
// to close the menu or traverse its items.
testWidgets('Tab traversal is not handled.', (WidgetTester tester) async {
testWidgets('Tab traversal is not handled', (WidgetTester tester) async {
final FocusNode bFocusNode = FocusNode(debugLabel: Tag.b.focusNode);
final FocusNode bbFocusNode = FocusNode(debugLabel: Tag.b.b.focusNode);
addTearDown(bFocusNode.dispose);
@ -1730,8 +1894,6 @@ void main() {
});
testWidgets('Menu closes on view size change', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view);
bool opened = false;
@ -1741,25 +1903,18 @@ void main() {
return MediaQuery(
data: mediaQueryData.copyWith(size: size),
child: App(
SingleChildScrollView(
controller: scrollController,
child: Container(
height: 1000,
alignment: Alignment.center,
child: Menu(
onOpen: () {
opened = true;
closed = false;
},
onClose: () {
opened = false;
closed = true;
},
controller: controller,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
Menu(
onOpen: () {
opened = true;
closed = false;
},
onClose: () {
opened = false;
closed = true;
},
controller: controller,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
);
@ -2122,6 +2277,602 @@ void main() {
expect(tester.takeException(), isNull);
});
group('onOpenRequested', () {
testWidgets('Triggered by MenuController.open', (WidgetTester tester) async {
final MenuController controller = MenuController();
int onOpenCalled = 0;
int onOpenRequestedCalled = 0;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onOpen: () {
onOpenCalled += 1;
},
onOpenRequested: (ui.Offset? position, VoidCallback showMenu) {
onOpenRequestedCalled += 1;
},
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
child: const AnchorButton(Tag.anchor),
),
),
);
expect(onOpenCalled, equals(0));
expect(onOpenRequestedCalled, equals(0));
controller.open();
await tester.pump();
expect(onOpenCalled, equals(0));
expect(onOpenRequestedCalled, equals(1));
});
testWidgets('Is passed a position', (WidgetTester tester) async {
final MenuController controller = MenuController();
ui.Offset? menuPosition;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
menuPosition = position;
},
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
child: const AnchorButton(Tag.anchor),
),
),
);
expect(menuPosition, isNull);
controller.open();
await tester.pump();
expect(menuPosition, isNull);
controller.close();
await tester.pump();
expect(menuPosition, isNull);
controller.open(position: const Offset(10, 15));
await tester.pump();
expect(menuPosition, equals(const Offset(10, 15)));
});
testWidgets('showOverlay triggers onOpen', (WidgetTester tester) async {
final MenuController controller = MenuController();
int onOpenCalled = 0;
int onOpenRequestedCalled = 0;
VoidCallback? showMenuOverlay;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onOpen: () {
onOpenCalled += 1;
},
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
onOpenRequestedCalled += 1;
showMenuOverlay = showOverlay;
},
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
child: const AnchorButton(Tag.anchor),
),
),
);
expect(onOpenRequestedCalled, equals(0));
expect(onOpenCalled, equals(0));
controller.open();
await tester.pump();
expect(onOpenRequestedCalled, equals(1));
expect(onOpenCalled, equals(0));
controller.open();
await tester.pump();
// Calling open again will trigger onOpenRequested again.
expect(onOpenRequestedCalled, equals(2));
expect(onOpenCalled, equals(0));
showMenuOverlay!();
await tester.pump();
expect(onOpenRequestedCalled, equals(2));
expect(onOpenCalled, equals(1));
// Calling showOverlay again will trigger onOpen again.
showMenuOverlay!();
await tester.pump();
expect(onOpenRequestedCalled, equals(2));
expect(onOpenCalled, equals(2));
});
testWidgets('showOverlay shows the menu at a position', (WidgetTester tester) async {
final MenuController controller = MenuController();
await tester.pumpWidget(
App(
Menu(
controller: controller,
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
Timer(const Duration(milliseconds: 100), () {
showOverlay();
});
},
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
top: info.position?.dy ?? 0,
left: info.position?.dx ?? 0,
child: SizedBox.square(key: Tag.a.key, dimension: 300),
);
},
child: const AnchorButton(Tag.anchor),
),
),
);
expect(find.byKey(Tag.a.key), findsNothing);
controller.open();
await tester.pump();
expect(find.byKey(Tag.a.key), findsNothing);
await tester.pump(const Duration(milliseconds: 101));
expect(find.byKey(Tag.a.key), findsOneWidget);
expect(tester.getTopLeft(find.byKey(Tag.a.key)), Offset.zero);
controller.open(position: const Offset(50, 50));
await tester.pump();
await tester.pump(const Duration(milliseconds: 101));
expect(find.byKey(Tag.a.key), findsOneWidget);
expect(tester.getTopLeft(find.byKey(Tag.a.key)), const Offset(50, 50));
});
testWidgets('showOverlay does nothing after the menu is disposed', (WidgetTester tester) async {
VoidCallback? showMenuOverlay;
int onOpenCalled = 0;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onOpen: () {
onOpenCalled += 1;
},
onOpenRequested: (ui.Offset? position, VoidCallback showOverlay) {
showMenuOverlay = showOverlay;
},
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
},
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
showMenuOverlay!();
await tester.pump();
expect(onOpenCalled, equals(1));
expect(find.text('Overlay'), findsOneWidget);
controller.close();
await tester.pump();
await tester.pumpWidget(const App(SizedBox()));
showMenuOverlay!();
expect(onOpenCalled, equals(1));
});
});
group('onCloseRequested', () {
testWidgets('Triggered by MenuController.close', (WidgetTester tester) async {
final MenuController controller = MenuController();
int onCloseRequestedCalled = 0;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onCloseRequested: (VoidCallback hideOverlay) {
onCloseRequestedCalled += 1;
},
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
child: const AnchorButton(Tag.anchor),
),
),
);
expect(onCloseRequestedCalled, equals(0));
controller.open();
await tester.pump();
expect(onCloseRequestedCalled, equals(0));
controller.close();
await tester.pump();
expect(onCloseRequestedCalled, equals(1));
});
testWidgets('hideOverlay triggers onClose', (WidgetTester tester) async {
final MenuController controller = MenuController();
int onCloseCalled = 0;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onClose: () {
onCloseCalled += 1;
},
onCloseRequested: (VoidCallback hideOverlay) {
Timer(const Duration(milliseconds: 100), hideOverlay);
},
menuPanel: const Panel(children: <Widget>[Text('Button 1')]),
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
await tester.pump();
controller.close();
await tester.pump();
expect(onCloseCalled, equals(0));
await tester.pump(const Duration(milliseconds: 99));
expect(onCloseCalled, equals(0));
await tester.pump(const Duration(milliseconds: 2));
expect(onCloseCalled, equals(1));
});
testWidgets('hideOverlay hides the menu overlay', (WidgetTester tester) async {
final MenuController controller = MenuController();
await tester.pumpWidget(
App(
Menu(
controller: controller,
onCloseRequested: (VoidCallback hideOverlay) {
Timer(const Duration(milliseconds: 100), hideOverlay);
},
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
},
child: const AnchorButton(Tag.anchor),
),
),
);
expect(find.text('Overlay'), findsNothing);
controller.open();
await tester.pump();
expect(find.text('Overlay'), findsOneWidget);
controller.close();
await tester.pump();
await tester.pump(const Duration(milliseconds: 99));
expect(find.text('Overlay'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2));
expect(find.text('Overlay'), findsNothing);
});
testWidgets('hideOverlay does nothing when called after disposal', (WidgetTester tester) async {
VoidCallback? hideMenuOverlay;
int onCloseCalled = 0;
await tester.pumpWidget(
App(
Menu(
controller: controller,
onClose: () {
onCloseCalled += 1;
},
onCloseRequested: (VoidCallback hideOverlay) {
hideMenuOverlay = hideOverlay;
},
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return const SizedBox.square(dimension: 300, child: Text('Overlay'));
},
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
await tester.pump();
expect(find.text('Overlay'), findsOneWidget);
controller.close();
hideMenuOverlay!();
await tester.pump();
expect(onCloseCalled, equals(1));
expect(find.text('Overlay'), findsNothing);
controller.open();
await tester.pump();
expect(find.text('Overlay'), findsOneWidget);
await tester.pumpWidget(const App(SizedBox()));
hideMenuOverlay!();
expect(onCloseCalled, equals(1));
});
testWidgets('DismissMenuAction triggers onCloseRequested', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
int topCloseRequests = 0;
int middleCloseRequests = 0;
int bottomCloseRequests = 0;
await tester.pumpWidget(
App(
Menu(
onCloseRequested: (VoidCallback hideOverlay) {
topCloseRequests += 1;
Timer(const Duration(milliseconds: 100), hideOverlay);
},
menuPanel: Panel(
children: <Widget>[
Menu(
onCloseRequested: (VoidCallback hideOverlay) {
middleCloseRequests += 1;
},
menuPanel: Panel(
children: <Widget>[
Menu(
controller: controller,
onCloseRequested: (VoidCallback hideOverlay) {
bottomCloseRequests += 1;
Timer(const Duration(milliseconds: 10), hideOverlay);
},
menuPanel: Panel(children: <Widget>[Text(Tag.a.a.a.text)]),
child: AnchorButton(Tag.a.a, focusNode: focusNode),
),
],
),
child: const AnchorButton(Tag.a),
),
],
),
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.tap(find.text(Tag.a.text));
await tester.pump();
await tester.tap(find.text(Tag.a.a.text));
await tester.pump();
focusNode.requestFocus();
await tester.pump();
expect(find.text(Tag.a.a.a.text), findsOneWidget);
const ActionDispatcher().invokeAction(
DismissMenuAction(controller: controller),
const DismissIntent(),
focusNode.context,
);
await tester.pump();
expect(topCloseRequests, equals(1));
expect(middleCloseRequests, equals(1));
expect(bottomCloseRequests, equals(1));
expect(find.text(Tag.a.a.a.text), findsOneWidget);
await tester.pump(const Duration(milliseconds: 11));
expect(topCloseRequests, equals(1));
expect(middleCloseRequests, equals(1));
expect(bottomCloseRequests, equals(1));
expect(find.text(Tag.a.a.a.text), findsNothing);
expect(find.text(Tag.a.text), findsOneWidget);
await tester.pump(const Duration(milliseconds: 88));
expect(topCloseRequests, equals(1));
expect(middleCloseRequests, equals(1));
expect(bottomCloseRequests, equals(1));
expect(find.text(Tag.a.a.a.text), findsNothing);
expect(find.text(Tag.a.text), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2));
expect(topCloseRequests, equals(1));
// Middle menu closes again in response to the outer menu calling
// "hideOverlay"
expect(middleCloseRequests, equals(2));
expect(bottomCloseRequests, equals(1));
expect(find.text(Tag.a.a.a.text), findsNothing);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('Outside tap triggers onCloseRequested', (WidgetTester tester) async {
int closeRequests = 0;
await tester.pumpWidget(
App(
Menu(
onCloseRequested: (VoidCallback hideOverlay) {
closeRequests += 1;
hideOverlay();
},
child: const AnchorButton(Tag.anchor),
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
left: info.anchorRect.left,
top: info.anchorRect.bottom,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {
MenuController.maybeOf(context)?.close();
},
child: Text(Tag.a.text),
),
);
},
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(closeRequests, equals(0));
// Tap outside the menu to trigger the close request.
await tester.tapAt(Offset.zero);
await tester.pump();
expect(closeRequests, equals(1));
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('View size change triggers onCloseRequested on open menu', (
WidgetTester tester,
) async {
final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view);
int onCloseRequestedCalled = 0;
Widget build(Size size) {
return MediaQuery(
data: mediaQueryData.copyWith(size: size),
child: App(
Menu(
onCloseRequested: (VoidCallback hideOverlay) {
onCloseRequestedCalled += 1;
hideOverlay();
},
controller: controller,
menuPanel: Panel(children: <Widget>[Text(Tag.a.text)]),
child: const AnchorButton(Tag.anchor),
),
),
);
}
await tester.pumpWidget(build(mediaQueryData.size));
await tester.pump();
// Test menu resize before first open.
await tester.pumpWidget(build(const Size(250, 250)));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(onCloseRequestedCalled, equals(0));
expect(find.text(Tag.a.text), findsOneWidget);
await tester.pumpWidget(build(const Size(200, 200)));
expect(onCloseRequestedCalled, equals(1));
await tester.pump();
expect(controller.isOpen, isFalse);
// Test menu resize after close.
await tester.pumpWidget(build(const Size(300, 300)));
expect(onCloseRequestedCalled, equals(1));
});
testWidgets('Ancestor scroll triggers onCloseRequested on open menu', (
WidgetTester tester,
) async {
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
int onCloseRequestedCalled = 0;
await tester.pumpWidget(
App(
SingleChildScrollView(
controller: scrollController,
child: SizedBox(
height: 1000,
child: Menu(
onCloseRequested: (VoidCallback hideOverlay) {
onCloseRequestedCalled += 1;
hideOverlay();
},
menuPanel: Panel(
children: <Widget>[
Button.tag(Tag.a),
Button.tag(Tag.b),
Button.tag(Tag.c),
Button.tag(Tag.d),
],
),
child: const AnchorButton(Tag.anchor),
),
),
),
),
);
scrollController.jumpTo(10);
await tester.pump();
// The menu is not open, so onCloseRequested should not be called.
expect(onCloseRequestedCalled, equals(0));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(onCloseRequestedCalled, equals(0));
scrollController.jumpTo(500);
await tester.pump();
// Make sure we didn't just scroll the menu off-screen.
expect(find.text(Tag.anchor.text), findsOneWidget);
expect(onCloseRequestedCalled, equals(1));
expect(find.text(Tag.a.text), findsNothing);
scrollController.jumpTo(5);
await tester.pump();
expect(find.text(Tag.anchor.text), findsOneWidget);
expect(onCloseRequestedCalled, equals(1));
});
});
}
// ********* UTILITIES ********* //
@ -2297,6 +3048,8 @@ class _ButtonState extends State<Button> {
@override
void dispose() {
_internalFocusNode?.dispose();
_states.dispose();
_internalFocusNode = null;
super.dispose();
}
@ -2446,7 +3199,7 @@ class Panel extends StatelessWidget {
class Menu extends StatefulWidget {
const Menu({
super.key,
required this.menuPanel,
this.menuPanel,
this.controller,
this.child,
this.builder,
@ -2455,14 +3208,29 @@ class Menu extends StatefulWidget {
this.onClose,
this.useRootOverlay = false,
this.consumeOutsideTaps = false,
this.overlayBuilder,
this.onOpenRequested = _defaultOnOpenRequested,
this.onCloseRequested = _defaultOnCloseRequested,
});
final Widget menuPanel;
static void _defaultOnOpenRequested(Offset? position, VoidCallback showOverlay) {
showOverlay();
}
static void _defaultOnCloseRequested(VoidCallback hideOverlay) {
hideOverlay();
}
final Widget? menuPanel;
final Widget? child;
final bool useRootOverlay;
final VoidCallback? onOpen;
final VoidCallback? onClose;
final FocusNode? focusNode;
final RawMenuAnchorChildBuilder? builder;
final RawMenuAnchorOverlayBuilder? overlayBuilder;
final RawMenuAnchorOpenRequestedCallback onOpenRequested;
final RawMenuAnchorCloseRequestedCallback onCloseRequested;
final MenuController? controller;
final bool consumeOutsideTaps;
@ -2479,16 +3247,20 @@ class _MenuState extends State<Menu> {
controller: widget.controller ?? (_controller ??= MenuController()),
onOpen: widget.onOpen,
onClose: widget.onClose,
onOpenRequested: widget.onOpenRequested,
onCloseRequested: widget.onCloseRequested,
consumeOutsideTaps: widget.consumeOutsideTaps,
useRootOverlay: widget.useRootOverlay,
builder: widget.builder,
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
top: info.anchorRect.bottom,
left: info.anchorRect.left,
child: widget.menuPanel,
);
},
overlayBuilder:
widget.overlayBuilder ??
(BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
top: info.anchorRect.bottom,
left: info.anchorRect.left,
child: widget.menuPanel!,
);
},
child: widget.child,
);
}