diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index cf7feb6ba9..88a489b432 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -732,6 +732,8 @@ class _PopupMenuRoute extends PopupRoute { this.shape, this.color, required this.capturedThemes, + this.menuKey, + this.positionCallback, }) : itemSizes = List.filled(items.length, null); final RelativeRect position; @@ -743,6 +745,8 @@ class _PopupMenuRoute extends PopupRoute { final ShapeBorder? shape; final Color? color; final CapturedThemes capturedThemes; + final Key? menuKey; + final PopupMenuButtonPositionCallback? positionCallback; @override Animation createAnimation() { @@ -778,12 +782,13 @@ class _PopupMenuRoute extends PopupRoute { final Widget menu = _PopupMenu(route: this, semanticLabel: semanticLabel); - return Builder( - builder: (BuildContext context) { + return StatefulBuilder( + key: menuKey, + builder: (BuildContext context, StateSetter setState) { final MediaQueryData mediaQuery = MediaQuery.of(context); return CustomSingleChildLayout( delegate: _PopupMenuRouteLayout( - position, + positionCallback == null ? position : positionCallback!(), itemSizes, selectedItemIndex, Directionality.of(context), @@ -801,6 +806,10 @@ class _PopupMenuRoute extends PopupRoute { /// /// `items` should be non-null and not empty. /// +/// Prefer to use `positionCallback` to obtain position instead of 'position' +/// when `positionCallback` is non-null. In this way, the position of the menu +/// can be recalculated through this callback during the rebuild phase of the menu. +/// /// If `initialValue` is specified then the first item with a matching value /// will be highlighted and the value of `position` gives the rectangle whose /// vertical center will be aligned with the vertical center of the highlighted @@ -862,6 +871,8 @@ Future showMenu({ ShapeBorder? shape, Color? color, bool useRootNavigator = false, + Key? menuKey, + PopupMenuButtonPositionCallback? positionCallback, }) { assert(context != null); assert(position != null); @@ -891,6 +902,8 @@ Future showMenu({ shape: shape, color: color, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + menuKey: menuKey, + positionCallback: positionCallback, )); } @@ -1090,11 +1103,44 @@ class PopupMenuButton extends StatefulWidget { PopupMenuButtonState createState() => PopupMenuButtonState(); } +/// Signature for the callback used by [showMenu] to obtain the position of the +/// [PopupMenuButton]. +/// +/// Used by [showMenu]. +typedef PopupMenuButtonPositionCallback = RelativeRect Function(); + /// The [State] for a [PopupMenuButton]. /// /// See [showButtonMenu] for a way to programmatically open the popup menu /// of your button state. class PopupMenuButtonState extends State> { + GlobalKey _menuGlobalKey = GlobalKey(); + RelativeRect? _buttonPosition; + + RelativeRect _getButtonPosition() => _buttonPosition!; + + RelativeRect _calculateButtonPosition() { + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + return RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(widget.offset, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero) + widget.offset, ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + } + + void _maybeUpdateMenuPosition() { + WidgetsBinding.instance!.addPostFrameCallback((Duration duration) { + final RelativeRect newPosition = _calculateButtonPosition(); + if (newPosition != _buttonPosition) { + _menuGlobalKey.currentState?.setState(() {}); + _buttonPosition = newPosition; + } + }); + } + /// A method to show a popup menu with the items supplied to /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. /// @@ -1105,16 +1151,12 @@ class PopupMenuButtonState extends State> { /// show the menu of the button with `globalKey.currentState.showButtonMenu`. void showButtonMenu() { final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); - final RenderBox button = context.findRenderObject()! as RenderBox; - final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; - final RelativeRect position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(widget.offset, ancestor: overlay), - button.localToGlobal(button.size.bottomRight(Offset.zero) + widget.offset, ancestor: overlay), - ), - Offset.zero & overlay.size, - ); final List> items = widget.itemBuilder(context); + // It is possible that the fade-out animation of the menu has not finished + // yet, and the key needs to be regenerated at this time, otherwise there will + // be an exception of duplicate GlobalKey. + if (_menuGlobalKey.currentState != null) + _menuGlobalKey = GlobalKey(); // Only show the menu if there is something to show if (items.isNotEmpty) { showMenu( @@ -1122,9 +1164,11 @@ class PopupMenuButtonState extends State> { elevation: widget.elevation ?? popupMenuTheme.elevation, items: items, initialValue: widget.initialValue, - position: position, + position: _buttonPosition!, shape: widget.shape ?? popupMenuTheme.shape, color: widget.color ?? popupMenuTheme.color, + menuKey: _menuGlobalKey, + positionCallback: _getButtonPosition, ) .then((T? newValue) { if (!mounted) @@ -1148,6 +1192,18 @@ class PopupMenuButtonState extends State> { } } + @override + void didUpdateWidget(PopupMenuButton oldWidget) { + _maybeUpdateMenuPosition(); + super.didUpdateWidget(oldWidget); + } + + @override + void didChangeDependencies() { + _maybeUpdateMenuPosition(); + super.didChangeDependencies(); + } + @override Widget build(BuildContext context) { final bool enableFeedback = widget.enableFeedback diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index 19349d7dc6..d0da298120 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -2209,6 +2209,87 @@ void main() { await tester.pumpAndSettle(); expect(find.text('foo'), findsOneWidget); }); + + testWidgets('The opened menu should follow if the button\'s position changed', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + + Widget buildFrame(double width, double height) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + height: height, + width: width, + child: Center( + child: PopupMenuButton( + child: SizedBox( + key: buttonKey, + height: 10.0, + width: 10.0, + child: const ColoredBox( + color: Colors.pink, + ), + ), + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem(child: Text('-1-'), value: 1), + const PopupMenuItem(child: Text('-2-'), value: 2), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(100.0, 100.0)); + + // Open the menu. + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + // +--------+--------+ 100 + // | | | + // | | (50,50)| + // +--------+--------+ + // | | | + // | | | + // 100 +--------+--------+ + // + // The button is a rectangle of 10 * 10 size and is centered, + // so its top-left offset should be (45.0, 45.0). + Offset buttonOffset = tester.getTopLeft(find.byKey(buttonKey)); + expect(buttonOffset, const Offset(45.0, 45.0)); + + // The top-left corner of the menu and button should be aligned. + Offset popupMenuOffset = tester.getTopLeft(find.byType(SingleChildScrollView)); + expect(popupMenuOffset, buttonOffset); + + // Keep the menu opened and re-layout the screen. + await tester.pumpWidget(buildFrame(200.0, 300.0)); + + // +-----------+-----------+ 200 + // | | | + // | | | + // | | | + // | | | + // | | (100,150) | + // +-----------+-----------+ + // | | | + // | | | + // | | | + // | | | + // | | | + // 300 +-----------+-----------+ + // + // The button is a rectangle of 10 * 10 size and is centered, + // so its top-left offset should be (95.0, 145.0). + await tester.pump(); // Need a frame to update the menu. + buttonOffset = tester.getTopLeft(find.byKey(buttonKey)); + expect(buttonOffset, const Offset(95.0, 145.0)); + + // The popup menu should follow the button. + popupMenuOffset = tester.getTopLeft(find.byType(SingleChildScrollView)); + expect(popupMenuOffset, buttonOffset); + }); } class TestApp extends StatefulWidget {