Fix: Update PopupMenu position when layout changes (#157983)

This PR fixes an issue where PopupMenu doesn't update its position when
the screen layout changes (e.g. during rotation or window resizing).

## Changes
- Introduces `positionBuilder` instead of directly using position
- Calls positionBuilder after layout changes to get the updated position
- Adds tests to verify position updates correctly

## Related Issues
Fixes #152475 - PopupMenu retains wrong position on layout change

## Implementation Notes
This implementation uses a builder pattern to dynamically calculate
positions. This approach may be applicable to other popup widgets (like
DropdownButton mentioned in #156980) that have similar positioning
requirements.

<br>
<br>
<br>
<br>
<br>







*Replace this paragraph with a description of what this PR is changing
or adding, and why. Consider including before/after screenshots.*

*List which issues are fixed by this PR. You must list at least one
issue. An issue is not required if the PR fixes something trivial like a
typo.*

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## 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].
- [ ] 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
This commit is contained in:
YeungKC 2024-12-05 03:43:08 +09:00 committed by GitHub
parent f9f42bee99
commit efa7d52303
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 110 additions and 18 deletions

View File

@ -852,7 +852,8 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
class _PopupMenuRoute<T> extends PopupRoute<T> {
_PopupMenuRoute({
required this.position,
this.position,
this.positionBuilder,
required this.items,
required this.itemKeys,
this.initialValue,
@ -870,12 +871,15 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
super.settings,
super.requestFocus,
this.popUpAnimationStyle,
}) : itemSizes = List<Size?>.filled(items.length, null),
}) : assert((position != null) != (positionBuilder != null),
'Either position or positionBuilder must be provided.'),
itemSizes = List<Size?>.filled(items.length, null),
// Menus always cycle focus through their items irrespective of the
// focus traversal edge behavior set in the Navigator.
super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop);
final RelativeRect position;
final RelativeRect? position;
final PopupMenuPositionBuilder? positionBuilder;
final List<PopupMenuEntry<T>> items;
final List<GlobalKey> itemKeys;
final List<Size?> itemSizes;
@ -955,11 +959,11 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
removeBottom: true,
removeLeft: true,
removeRight: true,
child: Builder(
builder: (BuildContext context) {
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return CustomSingleChildLayout(
delegate: _PopupMenuRouteLayout(
position,
positionBuilder?.call(context, constraints) ?? position!,
itemSizes,
selectedItemIndex,
Directionality.of(context),
@ -984,10 +988,39 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
}
}
/// Show a popup menu that contains the `items` at `position`.
/// A builder that creates a [RelativeRect] to position a popup menu.
/// Both [BuildContext] and [BoxConstraints] are from the [PopupRoute] that
/// displays this menu.
///
/// The returned [RelativeRect] determines the position of the popup menu relative
/// to the bounds of the [Navigator]'s overlay. The menu dimensions are not yet
/// known when this callback is invoked, as they depend on the items and other
/// properties of the menu.
///
/// The coordinate system used by the [RelativeRect] has its origin at the top
/// left of the [Navigator]'s overlay. Positive y coordinates are down (below the
/// origin), and positive x coordinates are to the right of the origin.
///
/// See also:
///
/// * [RelativeRect.fromLTRB], which creates a [RelativeRect] from left, top,
/// right, and bottom coordinates.
/// * [RelativeRect.fromRect], which creates a [RelativeRect] from two [Rect]s,
/// one representing the size of the popup menu and one representing the size
/// of the overlay.
typedef PopupMenuPositionBuilder = RelativeRect Function(
BuildContext context, BoxConstraints constraints);
/// Shows a popup menu that contains the `items` at `position`.
///
/// The `items` parameter must not be empty.
///
/// Only one of [position] or [positionBuilder] should be provided. Providing both
/// throws an assertion error. The [positionBuilder] is called at the time the
/// menu is shown to compute its position and every time the layout is updated,
/// which is useful when the position needs
/// to be determined at runtime based on the current layout.
///
/// 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
@ -1047,7 +1080,8 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
/// semantics.
Future<T?> showMenu<T>({
required BuildContext context,
required RelativeRect position,
RelativeRect? position,
PopupMenuPositionBuilder? positionBuilder,
required List<PopupMenuEntry<T>> items,
T? initialValue,
double? elevation,
@ -1066,6 +1100,8 @@ Future<T?> showMenu<T>({
}) {
assert(items.isNotEmpty);
assert(debugCheckHasMaterialLocalizations(context));
assert((position != null) != (positionBuilder != null),
'Either position or positionBuilder must be provided.');
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
@ -1082,6 +1118,7 @@ Future<T?> showMenu<T>({
final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator);
return navigator.push(_PopupMenuRoute<T>(
position: position,
positionBuilder: positionBuilder,
items: items,
itemKeys: menuItemKeys,
initialValue: initialValue,
@ -1468,15 +1505,8 @@ class PopupMenuButton<T> extends StatefulWidget {
/// See [showButtonMenu] for a way to programmatically open the popup menu
/// of your button state.
class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
/// A method to show a popup menu with the items supplied to
/// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton].
///
/// By default, it is called when the user taps the button and [PopupMenuButton.enabled]
/// is set to `true`. Moreover, you can open the button by calling the method manually.
///
/// You would access your [PopupMenuButtonState] using a [GlobalKey] and
/// show the menu of the button with `globalKey.currentState.showButtonMenu`.
void showButtonMenu() {
RelativeRect _positionBuilder(BuildContext _, BoxConstraints constraints) {
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay = Navigator.of(
@ -1502,6 +1532,20 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
),
Offset.zero & overlay.size,
);
return position;
}
/// A method to show a popup menu with the items supplied to
/// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton].
///
/// By default, it is called when the user taps the button and [PopupMenuButton.enabled]
/// is set to `true`. Moreover, you can open the button by calling the method manually.
///
/// You would access your [PopupMenuButtonState] using a [GlobalKey] and
/// show the menu of the button with `globalKey.currentState.showButtonMenu`.
void showButtonMenu() {
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
final List<PopupMenuEntry<T>> items = widget.itemBuilder(context);
// Only show the menu if there is something to show
if (items.isNotEmpty) {
@ -1513,7 +1557,7 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
surfaceTintColor: widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor,
items: items,
initialValue: widget.initialValue,
position: position,
positionBuilder: _positionBuilder,
shape: widget.shape ?? popupMenuTheme.shape,
menuPadding: widget.menuPadding ?? popupMenuTheme.menuPadding,
color: widget.color ?? popupMenuTheme.color,

View File

@ -4387,6 +4387,54 @@ void main() {
await tester.pump();
expect(fieldFocusNode.hasFocus, isTrue);
});
// Regression test for https://github.com/flutter/flutter/issues/152475
testWidgets('PopupMenuButton updates position on orientation change', (WidgetTester tester) async {
const Size initialSize = Size(400, 800);
const Size newSize = Size(1024, 768);
await tester.binding.setSurfaceSize(initialSize);
final GlobalKey buttonKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: PopupMenuButton<int>(
key: buttonKey,
itemBuilder: (BuildContext context) => <PopupMenuItem<int>>[
const PopupMenuItem<int>(
value: 1,
child: Text('Option 1'),
),
],
),
),
),
),
);
await tester.tap(find.byType(PopupMenuButton<int>));
await tester.pumpAndSettle();
final Rect initialButtonRect = tester.getRect(find.byKey(buttonKey));
final Rect initialMenuRect = tester.getRect(find.text('Option 1'));
await tester.binding.setSurfaceSize(newSize);
await tester.pumpAndSettle();
final Rect newButtonRect = tester.getRect(find.byKey(buttonKey));
final Rect newMenuRect = tester.getRect(find.text('Option 1'));
expect(newButtonRect, isNot(equals(initialButtonRect)));
expect(newMenuRect, isNot(equals(initialMenuRect)));
expect(newMenuRect.topLeft - newButtonRect.topLeft, initialMenuRect.topLeft - initialButtonRect.topLeft);
await tester.binding.setSurfaceSize(null);
});
}
Matcher overlaps(Rect other) => OverlapsMatcher(other);