diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index 20c2f96c05a..295ae524826 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -1703,6 +1703,23 @@ class OverlayPortalController { } } +/// The location of the [Overlay] that an [OverlayPortal] renders its overlay +/// child on. +/// +/// This is typically used in [OverlayPortal]. +enum OverlayChildLocation { + /// The [OverlayPortal] renders its overlay child on the closest ancestor + /// [Overlay] above the widget tree. + nearestOverlay, + + /// The [OverlayPortal] renders its overlay child on the root [Overlay] above + /// the widget tree. + /// + /// In case of multi-view apps, the root [Overlay] refers to the first Overlay + /// below the View. + rootOverlay, +} + /// A widget that renders its overlay child on an [Overlay]. /// /// The overlay child is initially hidden until [OverlayPortalController.show] @@ -1784,22 +1801,31 @@ class OverlayPortal extends StatefulWidget { /// Creates an [OverlayPortal] that renders the widget [overlayChildBuilder] /// builds on the closest [Overlay] when [OverlayPortalController.show] is /// called. + /// + /// The [overlayLocation] sets which [Overlay] this widget attaches the widget + /// returned by [overlayChildBuilder] to. Defaults to + /// [OverlayChildLocation.nearestOverlay]. const OverlayPortal({ super.key, required this.controller, required this.overlayChildBuilder, + this.overlayLocation = OverlayChildLocation.nearestOverlay, this.child, - }) : _targetRootOverlay = false; + }); /// Creates an [OverlayPortal] that renders the widget [overlayChildBuilder] /// builds on the root [Overlay] when [OverlayPortalController.show] is /// called. + @Deprecated( + 'Use OverlayPortal with root overlay instead. ' + 'This feature was deprecated after v3.33.0-0.0.pre.', + ) const OverlayPortal.targetsRootOverlay({ super.key, required this.controller, required this.overlayChildBuilder, this.child, - }) : _targetRootOverlay = true; + }) : overlayLocation = OverlayChildLocation.rootOverlay; /// Creates an [OverlayPortal] that renders the widget `overlayChildBuilder` /// builds on the closest [Overlay] when [OverlayPortalController.show] is @@ -1822,16 +1848,22 @@ class OverlayPortal extends StatefulWidget { /// [CompositedTransformFollower] between the [OverlayPortal] and the [Overlay] /// may resulting in an incorrect child paint transform being provided to the /// `overlayChildBuilder` and will cause an assertion in debug mode. + /// + /// The [overlayLocation] sets which [Overlay] this widget attaches the widget + /// returned by `overlayChildBuilder` to. Defaults to + /// [OverlayChildLocation.nearestOverlay]. OverlayPortal.overlayChildLayoutBuilder({ Key? key, required OverlayPortalController controller, required OverlayChildLayoutBuilder overlayChildBuilder, + OverlayChildLocation overlayLocation = OverlayChildLocation.nearestOverlay, required Widget? child, }) : this( key: key, controller: controller, overlayChildBuilder: (_) => _OverlayChildLayoutBuilder(builder: overlayChildBuilder), child: child, + overlayLocation: overlayLocation, ); /// The controller to show, hide and bring to top the overlay child. @@ -1858,7 +1890,9 @@ class OverlayPortal extends StatefulWidget { /// A widget below this widget in the tree. final Widget? child; - final bool _targetRootOverlay; + /// The [Overlay] that the widget returns from [overlayChildBuilder] is + /// attached to. + final OverlayChildLocation overlayLocation; @override State createState() => _OverlayPortalState(); @@ -1883,11 +1917,11 @@ class _OverlayPortalState extends State { locationCache._theater == marker.theater; } - _OverlayEntryLocation _getLocation(int zOrderIndex, bool targetRootOverlay) { + _OverlayEntryLocation _getLocation(int zOrderIndex, OverlayChildLocation overlayLocation) { final _OverlayEntryLocation? cachedLocation = _locationCache; late final _RenderTheaterMarker marker = _RenderTheaterMarker.of( context, - targetRootOverlay: targetRootOverlay, + targetRootOverlay: overlayLocation == OverlayChildLocation.rootOverlay, ); final bool isCacheValid = cachedLocation != null && @@ -1941,7 +1975,7 @@ class _OverlayPortalState extends State { void didUpdateWidget(OverlayPortal oldWidget) { super.didUpdateWidget(oldWidget); _childModelMayHaveChanged = - _childModelMayHaveChanged || oldWidget._targetRootOverlay != widget._targetRootOverlay; + _childModelMayHaveChanged || oldWidget.overlayLocation != widget.overlayLocation; if (oldWidget.controller != widget.controller) { oldWidget.controller._attachTarget = null; _setupController(widget.controller); @@ -1990,7 +2024,7 @@ class _OverlayPortalState extends State { return _OverlayPortal(overlayLocation: null, overlayChild: null, child: widget.child); } return _OverlayPortal( - overlayLocation: _getLocation(zOrderIndex, widget._targetRootOverlay), + overlayLocation: _getLocation(zOrderIndex, widget.overlayLocation), overlayChild: _DeferredLayout(child: Builder(builder: widget.overlayChildBuilder)), child: widget.child, ); diff --git a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart index 4f1b216e32a..67e2acc0875 100644 --- a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart +++ b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart @@ -823,19 +823,14 @@ class _RawMenuAnchorState extends State with _RawMenuAnchorBaseMi ), ); - if (useRootOverlay) { - return OverlayPortal.targetsRootOverlay( - controller: _overlayController, - overlayChildBuilder: _buildOverlay, - child: child, - ); - } else { - return OverlayPortal( - controller: _overlayController, - overlayChildBuilder: _buildOverlay, - child: child, - ); - } + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildOverlay, + overlayLocation: useRootOverlay + ? OverlayChildLocation.rootOverlay + : OverlayChildLocation.nearestOverlay, + child: child, + ); } @override diff --git a/packages/flutter/test/widgets/overlay_portal_test.dart b/packages/flutter/test/widgets/overlay_portal_test.dart index c2cbfcae1e1..8534966f3f6 100644 --- a/packages/flutter/test/widgets/overlay_portal_test.dart +++ b/packages/flutter/test/widgets/overlay_portal_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; +import 'multi_view_testing.dart'; import 'semantics_tester.dart'; class _ManyRelayoutBoundaries extends StatelessWidget { @@ -1242,13 +1243,7 @@ void main() { ); final _RenderLayoutCounter overlayLayoutCounter = _RenderLayoutCounter(); int layoutCount = 0; - OverlayPortal Function({ - Widget? child, - required OverlayPortalController controller, - Key? key, - required WidgetBuilder overlayChildBuilder, - }) - constructorToUse = OverlayPortal.new; + OverlayChildLocation location = OverlayChildLocation.nearestOverlay; late StateSetter setState; // This tree has 3 nested Overlays. @@ -1278,9 +1273,10 @@ void main() { return Center( child: Builder( builder: (BuildContext context) { - return constructorToUse( + return OverlayPortal( key: widgetKey, controller: controller1, + overlayLocation: location, overlayChildBuilder: (BuildContext context) { return LayoutBuilder( builder: @@ -1331,7 +1327,7 @@ void main() { // Now targets the root overlay. setState(() { - constructorToUse = OverlayPortal.targetsRootOverlay; + location = OverlayChildLocation.rootOverlay; }); await tester.pump(); @@ -1344,6 +1340,198 @@ void main() { verifyTreeIsClean(); }); + testWidgets('Listens to overlay changes', (WidgetTester tester) async { + // Use global key to ensure `OverlayCatcher` will be reparented instead + // of destroyed when Overlay gets swapped. + const GlobalObjectKey container = GlobalObjectKey('container'); + final OverlayPortalController controller1 = OverlayPortalController(); + final UniqueKey overlayPortal = UniqueKey(); + final Widget overlayBody = SizedBox( + width: 100, + height: 100, + child: OverlayPortal( + controller: controller1, + overlayChildBuilder: (BuildContext context) => Placeholder(key: overlayPortal), + ), + ); + + final OverlayEntry overlayEntry1 = OverlayEntry( + builder: (BuildContext context) { + return Container(key: container, child: overlayBody); + }, + ); + addTearDown( + () => overlayEntry1 + ..remove() + ..dispose(), + ); + final OverlayEntry overlayEntry2 = OverlayEntry( + builder: (BuildContext context) { + return Container(key: container, child: overlayBody); + }, + ); + addTearDown( + () => overlayEntry2 + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OverlaySwaps(overlayEntry1, overlayEntry2), + ), + ); + + controller1.show(); + await tester.pump(); + + final RenderObject parentTheater = _ancestorRenderTheaters( + tester.renderObject(find.byKey(overlayPortal)), + ).single; + + final OverlaySwapsState swaps = tester.state(find.byType(OverlaySwaps)); + swaps.swaps(); + await tester.pump(); + + final RenderObject newParentTheater = _ancestorRenderTheaters( + tester.renderObject(find.byKey(overlayPortal)), + ).single; + expect(parentTheater, isNot(newParentTheater)); + }); + + testWidgets('Listens to root overlay changes', (WidgetTester tester) async { + final GlobalKey oldRoot = GlobalKey(); + final GlobalKey newRoot = GlobalKey(); + final UniqueKey overlayPortal = UniqueKey(); + final Widget overlayBody = SizedBox( + width: 100, + height: 100, + child: OverlayPortal( + controller: controller1, + overlayLocation: OverlayChildLocation.rootOverlay, + overlayChildBuilder: (BuildContext context) => Placeholder(key: overlayPortal), + ), + ); + + final OverlayEntry innerEntry = OverlayEntry( + builder: (BuildContext context) { + return Container(child: overlayBody); + }, + ); + addTearDown( + () => innerEntry + ..remove() + ..dispose(), + ); + + final OverlayEntry midEntry = OverlayEntry( + builder: (BuildContext context) { + return Overlay(initialEntries: [innerEntry]); + }, + ); + addTearDown( + () => midEntry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay(key: oldRoot, initialEntries: [midEntry]), + ), + ); + + RenderObject parentTheater = _ancestorRenderTheaters( + tester.renderObject(find.byKey(overlayPortal)), + ).single; + expect(parentTheater, oldRoot.currentContext?.findRenderObject()); + + final OverlayEntry outerEntry = OverlayEntry( + builder: (BuildContext context) { + return Overlay(key: oldRoot, initialEntries: [midEntry]); + }, + ); + addTearDown( + () => outerEntry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + // Add a new root. + key: newRoot, + initialEntries: [outerEntry], + ), + ), + ); + + parentTheater = _ancestorRenderTheaters(tester.renderObject(find.byKey(overlayPortal))).single; + expect(parentTheater, newRoot.currentContext?.findRenderObject()); + }); + + testWidgets('Root location uses view boundary', (WidgetTester tester) async { + final UniqueKey overlayPortal = UniqueKey(); + final GlobalKey outer = GlobalKey(); + final GlobalKey inner = GlobalKey(); + final Widget overlayBody = SizedBox( + width: 100, + height: 100, + child: OverlayPortal( + controller: controller1, + overlayLocation: OverlayChildLocation.rootOverlay, + overlayChildBuilder: (BuildContext context) => Placeholder(key: overlayPortal), + ), + ); + + final OverlayEntry innerEntry = OverlayEntry( + builder: (BuildContext context) { + return overlayBody; + }, + ); + addTearDown( + () => innerEntry + ..remove() + ..dispose(), + ); + + final OverlayEntry outerEntry = OverlayEntry( + builder: (BuildContext context) { + return ViewAnchor( + view: View( + view: FakeView(tester.view), + child: Overlay(key: inner, initialEntries: [innerEntry]), + ), + child: const Placeholder(), + ); + }, + ); + addTearDown( + () => outerEntry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay(key: outer, initialEntries: [outerEntry]), + ), + ); + + controller1.show(); + await tester.pumpAndSettle(); + + expect( + _ancestorRenderTheaters(tester.renderObject(find.byKey(overlayPortal))).single, + inner.currentContext?.findRenderObject(), + ); + }); + testWidgets('PortalController can be assigned to another after deactivate', ( WidgetTester tester, ) async { @@ -3037,3 +3225,36 @@ class _RenderPaintRecorder extends RenderProxyBox { super.paint(context, offset); } } + +class OverlaySwaps extends StatefulWidget { + const OverlaySwaps(this.entry, this.entryAfterSwap, {super.key}); + + final OverlayEntry entry; + final OverlayEntry entryAfterSwap; + @override + State createState() => OverlaySwapsState(); +} + +class OverlaySwapsState extends State { + late UniqueKey overlayKey; + late OverlayEntry entry; + + @override + void initState() { + super.initState(); + overlayKey = UniqueKey(); + entry = widget.entry; + } + + void swaps() { + setState(() { + overlayKey = UniqueKey(); + entry = widget.entryAfterSwap; + }); + } + + @override + Widget build(BuildContext context) { + return Overlay(key: overlayKey, initialEntries: [entry]); + } +}