Allow OverlayPortal.overlayChildLayoutBuilder to choose root Overlay (#174239)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

fixes https://github.com/flutter/flutter/issues/168785

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] 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.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- 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:
chunhtai 2025-09-04 11:27:19 -07:00 committed by GitHub
parent ad41861345
commit ae3eb26237
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 279 additions and 29 deletions

View File

@ -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<OverlayPortal> createState() => _OverlayPortalState();
@ -1883,11 +1917,11 @@ class _OverlayPortalState extends State<OverlayPortal> {
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<OverlayPortal> {
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<OverlayPortal> {
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,
);

View File

@ -823,19 +823,14 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> 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

View File

@ -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<OverlaySwapsState>(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<OverlayState> oldRoot = GlobalKey<OverlayState>();
final GlobalKey<OverlayState> newRoot = GlobalKey<OverlayState>();
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: <OverlayEntry>[innerEntry]);
},
);
addTearDown(
() => midEntry
..remove()
..dispose(),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(key: oldRoot, initialEntries: <OverlayEntry>[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: <OverlayEntry>[midEntry]);
},
);
addTearDown(
() => outerEntry
..remove()
..dispose(),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
// Add a new root.
key: newRoot,
initialEntries: <OverlayEntry>[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<OverlayState> outer = GlobalKey<OverlayState>();
final GlobalKey<OverlayState> inner = GlobalKey<OverlayState>();
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: <OverlayEntry>[innerEntry]),
),
child: const Placeholder(),
);
},
);
addTearDown(
() => outerEntry
..remove()
..dispose(),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(key: outer, initialEntries: <OverlayEntry>[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<StatefulWidget> createState() => OverlaySwapsState();
}
class OverlaySwapsState extends State<OverlaySwaps> {
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: <OverlayEntry>[entry]);
}
}