diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index d99c7e106fa..0f7732ca048 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -1568,7 +1568,17 @@ class PopupMenuButton extends StatefulWidget { /// of your button state. class PopupMenuButtonState extends State> { bool _isMenuExpanded = false; + RelativeRect? _lastPosition; + RelativeRect _positionBuilder(BuildContext _, BoxConstraints constraints) { + if (!mounted) { + // When the route is displayed, the `_positionBuilder` closure is stored. + // Even after the button has been unmounted and the context becomes invalid, + // the route might keep displaying, and `_positionBuilder` must continue to + // work in that case. + return _lastPosition ?? RelativeRect.fromSize(Rect.zero, constraints.biggest); + } + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final RenderBox button = context.findRenderObject()! as RenderBox; final RenderBox overlay = @@ -1598,7 +1608,7 @@ class PopupMenuButtonState extends State> { Offset.zero & overlay.size, ); - return position; + return _lastPosition = position; } /// A method to show a popup menu with the items supplied to diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index f51ab5ad78d..85f6c7987f5 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -2863,6 +2863,71 @@ void main() { expect(mediaQueryPadding, EdgeInsets.zero); }); + // Regression test for https://github.com/flutter/flutter/issues/163477 + testWidgets("PopupMenu's overlay can be rebuilt even when the button is unmounted", ( + WidgetTester tester, + ) async { + final GlobalKey buttonKey = GlobalKey(); + + late StateSetter setState; + bool showButton = true; + + Widget widget({required Size viewSize}) { + return Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter innerSetState) { + setState = innerSetState; + return showButton + ? PopupMenuButton( + key: buttonKey, + popUpAnimationStyle: const AnimationStyle( + reverseDuration: Duration(milliseconds: 400), + ), + itemBuilder: (BuildContext context) { + return >[ + PopupMenuItem(value: 1, child: const Text('ACTION'), onTap: () {}), + ]; + }, + ) + : Container(); + }, + ), + ), + ), + ), + ); + } + + // Pump a button + await tester.pumpWidget(widget(viewSize: const Size(500, 500))); + + // Tap the button to show the menu + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + expect(find.text('ACTION'), findsOne); + expect(find.byKey(buttonKey), findsOne); + + // Hide the button. The menu still shows since it's placed on a separate route. + setState(() { + showButton = false; + }); + await tester.pump(); + expect(find.text('ACTION'), findsOne); + expect(find.byKey(buttonKey), findsNothing); + + // Resize the view, causing the menu to rebuild. Before the fix, this + // rebuild would lead to a crash, because it relies on context of the button, + // which has been unmounted. + await tester.pumpWidget(widget(viewSize: const Size(300, 300))); + + expect(tester.takeException(), isNull); + }); + group('feedback', () { late FeedbackTester feedback;