Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 2b974ae

Browse files
Make popup menus avoid display features (#98981)
1 parent 231c1a4 commit 2b974ae

File tree

4 files changed

+151
-20
lines changed

4 files changed

+151
-20
lines changed

packages/flutter/lib/src/material/popup_menu.dart

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,7 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
636636
this.selectedItemIndex,
637637
this.textDirection,
638638
this.padding,
639+
this.avoidBounds,
639640
);
640641

641642
// Rectangle of underlying button, relative to the overlay's dimensions.
@@ -655,6 +656,9 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
655656
// The padding of unsafe area.
656657
EdgeInsets padding;
657658

659+
// List of rectangles that we should avoid overlapping. Unusable screen area.
660+
final Set<Rect> avoidBounds;
661+
658662
// We put the child wherever position specifies, so long as it will fit within
659663
// the specified parent size padded (inset) by 8. If necessary, we adjust the
660664
// child's position so that it fits.
@@ -705,19 +709,38 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
705709
break;
706710
}
707711
}
712+
final Offset wantedPosition = Offset(x, y);
713+
final Offset originCenter = position.toRect(Offset.zero & size).center;
714+
final Iterable<Rect> subScreens = DisplayFeatureSubScreen.subScreensInBounds(Offset.zero & size, avoidBounds);
715+
final Rect subScreen = _closestScreen(subScreens, originCenter);
716+
return _fitInsideScreen(subScreen, childSize, wantedPosition);
717+
}
718+
719+
Rect _closestScreen(Iterable<Rect> screens, Offset point) {
720+
Rect closest = screens.first;
721+
for (final Rect screen in screens) {
722+
if ((screen.center - point).distance < (closest.center - point).distance) {
723+
closest = screen;
724+
}
725+
}
726+
return closest;
727+
}
708728

729+
Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition){
730+
double x = wantedPosition.dx;
731+
double y = wantedPosition.dy;
709732
// Avoid going outside an area defined as the rectangle 8.0 pixels from the
710733
// edge of the screen in every direction.
711-
if (x < _kMenuScreenPadding + padding.left)
712-
x = _kMenuScreenPadding + padding.left;
713-
else if (x + childSize.width > size.width - _kMenuScreenPadding - padding.right)
714-
x = size.width - childSize.width - _kMenuScreenPadding - padding.right ;
715-
if (y < _kMenuScreenPadding + padding.top)
734+
if (x < screen.left + _kMenuScreenPadding + padding.left)
735+
x = screen.left + _kMenuScreenPadding + padding.left;
736+
else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right)
737+
x = screen.right - childSize.width - _kMenuScreenPadding - padding.right;
738+
if (y < screen.top + _kMenuScreenPadding + padding.top)
716739
y = _kMenuScreenPadding + padding.top;
717-
else if (y + childSize.height > size.height - _kMenuScreenPadding - padding.bottom)
718-
y = size.height - padding.bottom - _kMenuScreenPadding - childSize.height ;
740+
else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom)
741+
y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom;
719742

720-
return Offset(x, y);
743+
return Offset(x,y);
721744
}
722745

723746
@override
@@ -731,7 +754,8 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
731754
|| selectedItemIndex != oldDelegate.selectedItemIndex
732755
|| textDirection != oldDelegate.textDirection
733756
|| !listEquals(itemSizes, oldDelegate.itemSizes)
734-
|| padding != oldDelegate.padding;
757+
|| padding != oldDelegate.padding
758+
|| !setEquals(avoidBounds, oldDelegate.avoidBounds);
735759
}
736760
}
737761

@@ -813,13 +837,18 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
813837
selectedItemIndex,
814838
Directionality.of(context),
815839
mediaQuery.padding,
840+
_avoidBounds(mediaQuery),
816841
),
817842
child: capturedThemes.wrap(menu),
818843
);
819844
},
820845
),
821846
);
822847
}
848+
849+
Set<Rect> _avoidBounds(MediaQueryData mediaQuery) {
850+
return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet();
851+
}
823852
}
824853

825854
/// Show a popup menu that contains the `items` at `position`.

packages/flutter/lib/src/widgets/display_feature_sub_screen.dart

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'dart:math' as math;
6-
import 'dart:ui' show DisplayFeature;
6+
import 'dart:ui' show DisplayFeature, DisplayFeatureState;
77
import 'package:flutter/foundation.dart';
88
import 'package:flutter/gestures.dart';
99
import 'package:flutter/rendering.dart';
@@ -19,9 +19,8 @@ import 'media_query.dart';
1919
/// A [DisplayFeature] splits the screen into sub-screens when both these
2020
/// conditions are met:
2121
///
22-
/// * it obstructs the screen, meaning the area it occupies is not 0. Display
23-
/// features of type [DisplayFeatureType.fold] can have height 0 or width 0
24-
/// and not be obstructing the screen.
22+
/// * it obstructs the screen, meaning the area it occupies is not 0 or the
23+
/// `state` is [DisplayFeatureState.postureHalfOpened].
2524
/// * it is at least as tall as the screen, producing a left and right
2625
/// sub-screen or it is at least as wide as the screen, producing a top and
2726
/// bottom sub-screen
@@ -100,7 +99,7 @@ class DisplayFeatureSubScreen extends StatelessWidget {
10099
final Size parentSize = mediaQuery.size;
101100
final Rect wantedBounds = Offset.zero & parentSize;
102101
final Offset resolvedAnchorPoint = _capOffset(anchorPoint ?? _fallbackAnchorPoint(context), parentSize);
103-
final Iterable<Rect> subScreens = _subScreensInBounds(wantedBounds, _avoidBounds(mediaQuery));
102+
final Iterable<Rect> subScreens = subScreensInBounds(wantedBounds, avoidBounds(mediaQuery));
104103
final Rect closestSubScreen = _closestToAnchorPoint(subScreens, resolvedAnchorPoint);
105104

106105
return Padding(
@@ -127,9 +126,15 @@ class DisplayFeatureSubScreen extends StatelessWidget {
127126
}
128127
}
129128

130-
static Iterable<Rect> _avoidBounds(MediaQueryData mediaQuery) {
131-
return mediaQuery.displayFeatures.map((DisplayFeature d) => d.bounds)
132-
.where((Rect r) => r.shortestSide > 0);
129+
/// Returns the areas of the screen that are obstructed by display features.
130+
///
131+
/// A [DisplayFeature] obstructs the screen when the the area it occupies is
132+
/// not 0 or the `state` is [DisplayFeatureState.postureHalfOpened].
133+
static Iterable<Rect> avoidBounds(MediaQueryData mediaQuery) {
134+
return mediaQuery.displayFeatures
135+
.where((DisplayFeature d) => d.bounds.shortestSide > 0 ||
136+
d.state == DisplayFeatureState.postureHalfOpened)
137+
.map((DisplayFeature d) => d.bounds);
133138
}
134139

135140
/// Returns the closest sub-screen to the [anchorPoint].
@@ -188,8 +193,8 @@ class DisplayFeatureSubScreen extends StatelessWidget {
188193
}
189194

190195
/// Returns sub-screens resulted by dividing [wantedBounds] along items of
191-
/// [avoidBounds] that are at least as high or as wide.
192-
static Iterable<Rect> _subScreensInBounds(Rect wantedBounds, Iterable<Rect> avoidBounds) {
196+
/// [avoidBounds] that are at least as tall or as wide.
197+
static Iterable<Rect> subScreensInBounds(Rect wantedBounds, Iterable<Rect> avoidBounds) {
193198
Iterable<Rect> subScreens = <Rect>[wantedBounds];
194199
for (final Rect bounds in avoidBounds) {
195200
final List<Rect> newSubScreens = <Rect>[];

packages/flutter/test/material/popup_menu_test.dart

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'dart:ui' show SemanticsFlag;
5+
import 'dart:ui' show SemanticsFlag, DisplayFeature, DisplayFeatureType, DisplayFeatureState;
66

77
import 'package:flutter/foundation.dart';
88
import 'package:flutter/gestures.dart';
@@ -853,6 +853,66 @@ void main() {
853853
expect(tester.getTopLeft(popupFinder), buttonTopLeft);
854854
});
855855

856+
testWidgets('PopupMenu positioning around display features', (WidgetTester tester) async {
857+
final Key buttonKey = UniqueKey();
858+
859+
await tester.pumpWidget(
860+
MaterialApp(
861+
home: MediaQuery(
862+
data: const MediaQueryData(
863+
size: Size(800, 600),
864+
displayFeatures: <DisplayFeature>[
865+
// A 20-pixel wide vertical display feature, similar to a foldable
866+
// with a visible hinge. Splits the display into two "virtual screens"
867+
// and the popup menu should never overlap the display feature.
868+
DisplayFeature(
869+
bounds: Rect.fromLTRB(390, 0, 410, 600),
870+
type: DisplayFeatureType.cutout,
871+
state: DisplayFeatureState.unknown,
872+
)
873+
]
874+
),
875+
child: Scaffold(
876+
body: Navigator(
877+
onGenerateRoute: (RouteSettings settings) {
878+
return MaterialPageRoute<dynamic>(
879+
builder: (BuildContext context) {
880+
return Padding(
881+
// Position the button in the top-right of the first "virtual screen"
882+
padding: const EdgeInsets.only(right:390.0),
883+
child: Align(
884+
alignment: Alignment.topRight,
885+
child: PopupMenuButton<int>(
886+
key: buttonKey,
887+
itemBuilder: (_) => <PopupMenuItem<int>>[
888+
const PopupMenuItem<int>(value: 1, child: Text('Item 1')),
889+
const PopupMenuItem<int>(value: 2, child: Text('Item 2')),
890+
],
891+
child: const Text('Show Menu'),
892+
),
893+
),
894+
);
895+
},
896+
);
897+
},
898+
),
899+
),
900+
),
901+
),
902+
);
903+
904+
final Finder buttonFinder = find.byKey(buttonKey);
905+
final Finder popupFinder = find.bySemanticsLabel('Popup menu');
906+
await tester.tap(buttonFinder);
907+
await tester.pumpAndSettle();
908+
909+
// Since the display feature splits the display into 2 sub-screens, popup
910+
// menu should be positioned to fit in the first virtual screen, where the
911+
// originating button is.
912+
// The 8 pixels is [_kMenuScreenPadding].
913+
expect(tester.getTopRight(popupFinder), const Offset(390 - 8, 8));
914+
});
915+
856916
testWidgets('PopupMenu removes MediaQuery padding', (WidgetTester tester) async {
857917
late BuildContext popupContext;
858918

packages/flutter/test/widgets/display_feature_sub_screen_test.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,11 @@ void main() {
192192
type: DisplayFeatureType.cutout,
193193
state: DisplayFeatureState.unknown,
194194
),
195+
const DisplayFeature(
196+
bounds: Rect.fromLTRB(0, 300, 800, 300),
197+
type: DisplayFeatureType.fold,
198+
state: DisplayFeatureState.postureFlat,
199+
),
195200
]
196201
);
197202

@@ -217,5 +222,37 @@ void main() {
217222
expect(renderBox.size.height, equals(600.0));
218223
expect(renderBox.localToGlobal(Offset.zero), equals(Offset.zero));
219224
});
225+
226+
testWidgets('with size 0 display feature in half-opened posture and anchorPoint', (WidgetTester tester) async {
227+
const Key childKey = Key('childKey');
228+
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance.window).copyWith(
229+
displayFeatures: <DisplayFeature>[
230+
const DisplayFeature(
231+
bounds: Rect.fromLTRB(0, 300, 800, 300),
232+
type: DisplayFeatureType.fold,
233+
state: DisplayFeatureState.postureHalfOpened,
234+
),
235+
]
236+
);
237+
238+
await tester.pumpWidget(
239+
MediaQuery(
240+
data: mediaQuery,
241+
child: const DisplayFeatureSubScreen(
242+
anchorPoint: Offset(1000, 1000),
243+
child: SizedBox(
244+
key: childKey,
245+
width: double.infinity,
246+
height: double.infinity,
247+
),
248+
),
249+
),
250+
);
251+
252+
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
253+
expect(renderBox.size.width, equals(800.0));
254+
expect(renderBox.size.height, equals(300.0));
255+
expect(renderBox.localToGlobal(Offset.zero), equals(const Offset(0,300)));
256+
});
220257
});
221258
}

0 commit comments

Comments
 (0)