blob: 7b6d919a431868eec5efae1cb6fabbfa5d83b867 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
import 'semantics_tester.dart';
class _ManyRelayoutBoundaries extends StatelessWidget {
const _ManyRelayoutBoundaries({
required this.levels,
required this.child,
});
final Widget child;
final int levels;
@override
Widget build(BuildContext context) {
final Widget result = levels <= 1
? child
: _ManyRelayoutBoundaries(levels: levels - 1, child: child);
return SizedBox.square(dimension: 50, child: result);
}
}
void rebuildLayoutBuilderSubtree(RenderBox descendant) {
assert(descendant is! RenderConstrainedLayoutBuilder<BoxConstraints, RenderBox>);
RenderObject? node = descendant.parent;
while (node != null) {
if (node is! RenderConstrainedLayoutBuilder<BoxConstraints, RenderBox>) {
node = node.parent;
} else {
node.markNeedsBuild();
return;
}
}
assert(false);
}
void verifyTreeIsClean() {
final RenderObject renderObject = RendererBinding.instance.renderView;
bool hasDirtyNode = renderObject.debugNeedsLayout;
void visitor(RenderObject renderObject) {
expect(renderObject.debugNeedsLayout, false, reason: '$renderObject is dirty');
hasDirtyNode = hasDirtyNode || renderObject.debugNeedsLayout;
if (!hasDirtyNode) {
renderObject.visitChildren(visitor);
}
}
visitor(renderObject);
}
void verifyOverlayChildReadyForLayout(GlobalKey overlayWidgetKey) {
final RenderBox layoutSurrogate = overlayWidgetKey.currentContext!.findRenderObject()! as RenderBox;
assert(
layoutSurrogate.runtimeType.toString() == '_RenderLayoutSurrogateProxyBox',
layoutSurrogate.runtimeType,
);
if (layoutSurrogate.debugNeedsLayout) {
assert(layoutSurrogate.debugDoingThisLayout);
}
expect(!layoutSurrogate.debugNeedsLayout || layoutSurrogate.debugDoingThisLayout, true);
}
List<RenderObject> _ancestorRenderTheaters(RenderObject child) {
final List<RenderObject> results = <RenderObject>[];
RenderObject? node = child;
while (node != null) {
if (node.runtimeType.toString() == '_RenderTheater') {
results.add(node);
}
final RenderObject? parent = node.parent;
node = parent is RenderObject? parent : null;
}
return results;
}
void main() {
final OverlayPortalController controller1 = OverlayPortalController(debugLabel: 'controller1');
final OverlayPortalController controller2 = OverlayPortalController(debugLabel: 'controller2');
final OverlayPortalController controller3 = OverlayPortalController(debugLabel: 'controller3');
final OverlayPortalController controller4 = OverlayPortalController(debugLabel: 'controller4');
setUp(() {
controller1.show();
controller2.show();
controller3.show();
controller4.show();
_PaintOrder.paintOrder.clear();
});
testWidgets('The overlay child sees the right inherited widgets', (WidgetTester tester) async {
int buildCount = 0;
TextDirection? directionSeenByOverlayChild;
TextDirection textDirection = TextDirection.rtl;
late StateSetter setState;
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return Directionality(
textDirection: textDirection,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
buildCount += 1;
directionSeenByOverlayChild = Directionality.maybeOf(context);
return const SizedBox();
},
child: const SizedBox(),
),
);
}
);
},
),
],
),
),
);
expect(buildCount, 1);
expect(directionSeenByOverlayChild, textDirection);
setState(() {
textDirection = TextDirection.ltr;
});
await tester.pump();
expect(buildCount, 2);
expect(directionSeenByOverlayChild, textDirection);
});
testWidgets('Safe to deactivate and re-activate OverlayPortal', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final Widget widget = Directionality(
key: GlobalKey(debugLabel: 'key'),
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
},
),
],
),
);
await tester.pumpWidget(widget);
await tester.pumpWidget(SizedBox(child: widget));
});
testWidgets('Safe to hide overlay child and remove OverlayPortal in the same frame', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/129025.
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final Widget widget = Directionality(
key: GlobalKey(debugLabel: 'key'),
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
},
),
],
),
);
controller1.show();
await tester.pumpWidget(widget);
controller1.hide();
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
});
testWidgets('Safe to hide overlay child and reparent OverlayPortal in the same frame', (WidgetTester tester) async {
final OverlayPortal overlayPortal = OverlayPortal(
key: GlobalKey(debugLabel: 'key'),
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
List<Widget> children = <Widget>[ const SizedBox(), overlayPortal ];
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
late StateSetter setState;
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayStatefulEntry(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return Column(children: children);
},
),
],
),
);
controller1.show();
await tester.pumpWidget(widget);
controller1.hide();
setState(() {
children = <Widget>[ overlayPortal, const SizedBox() ];
});
await tester.pumpWidget(widget);
expect(tester.takeException(), isNull);
});
testWidgets('Safe to hide overlay child and reparent OverlayPortal in the same frame 2', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final Widget widget = Directionality(
key: GlobalKey(debugLabel: 'key'),
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
},
),
],
),
);
controller1.show();
await tester.pumpWidget(widget);
controller1.hide();
await tester.pumpWidget(SizedBox(child: widget));
expect(tester.takeException(), isNull);
});
testWidgets(
'Overlay child remains accessible via tree walk when there is no relayout boundary between OverlayPortal and Overlay',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/133545.
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final GlobalKey key = GlobalKey(debugLabel: 'key');
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
// The Positioned widget prevents a relayout boundary from being
// introduced between the Overlay and OverlayPortal.
return Positioned(
top: 0,
left: 0,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => SizedBox(key: key),
child: const SizedBox(),
),
);
},
),
],
),
);
controller1.hide();
await tester.pumpWidget(widget);
controller1.show();
await tester.pump();
expect(find.byKey(key), findsOneWidget);
expect(tester.takeException(), isNull);
verifyTreeIsClean();
});
testWidgets('Throws when the same controller is attached to multiple OverlayPortal',
experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), // leaking by design because of exception
(WidgetTester tester) async {
final OverlayPortalController controller = OverlayPortalController(debugLabel: 'local controller');
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) {
return Column(
children: <Widget>[
OverlayPortal(
controller: controller,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
),
OverlayPortal(
controller: controller,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
),
],
);
},
),
],
),
);
await tester.pumpWidget(widget);
expect(
tester.takeException().toString(),
stringContainsInOrder(<String>['Failed to attach' ,'It is already attached to']),
);
});
testWidgets('show/hide works', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final OverlayPortalController controller = OverlayPortalController(debugLabel: 'local controller');
const Widget target = SizedBox();
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller,
overlayChildBuilder: (BuildContext context) => target,
);
},
),
],
),
);
await tester.pumpWidget(widget);
expect(find.byWidget(target), findsNothing);
await tester.pump();
expect(find.byWidget(target), findsNothing);
controller.show();
await tester.pump();
expect(find.byWidget(target), findsOneWidget);
controller.hide();
await tester.pump();
expect(find.byWidget(target), findsNothing);
controller.show();
await tester.pump();
expect(find.byWidget(target), findsOneWidget);
});
testWidgets('overlayChildBuilder is not evaluated until show is called', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
final OverlayPortalController controller = OverlayPortalController(debugLabel: 'local controller');
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller,
overlayChildBuilder: (BuildContext context) => throw StateError('Unreachable'),
child: const SizedBox(),
);
},
),
],
),
);
await tester.pumpWidget(widget);
expect(tester.takeException(), isNull);
});
testWidgets('overlay child can use Positioned', (WidgetTester tester) async {
double dimensions = 30;
late StateSetter setState;
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return Positioned(
width: dimensions,
height: dimensions,
child: const Placeholder(),
);
},
child: const SizedBox(),
);
}
);
},
),
],
),
),
);
expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero) ;
expect(tester.getSize(find.byType(Placeholder)), const Size(30, 30)) ;
setState(() {
dimensions = 50;
});
await tester.pump();
expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero) ;
expect(tester.getSize(find.byType(Placeholder)), const Size(50, 50)) ;
});
testWidgets('overlay child can be hit tested', (WidgetTester tester) async {
double offset = 0;
late StateSetter setState;
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
bool isHit = false;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return Positioned(
left: offset,
top: offset,
width: 1.0,
height: 1.0,
child: GestureDetector(onTap: () { isHit = true; }),
);
},
child: const SizedBox(),
);
}
);
},
),
],
),
),
);
assert(!isHit);
await tester.tapAt(const Offset(0.5, 0.5));
expect(isHit, true);
isHit = false;
setState(() {
offset = 50;
});
await tester.pump();
assert(!isHit);
await tester.tapAt(const Offset(0.5, 0.5));
expect(isHit, false);
isHit = false;
await tester.tapAt(const Offset(50.5, 50.5));
expect(isHit, true);
});
testWidgets('works in a LayoutBuilder', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
}
);
},
),
],
),
),
);
expect(tester.takeException(), isNull);
});
testWidgets('works in a LayoutBuilder 2', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
late StateSetter setState;
bool shouldShowChild = false;
Widget layoutBuilder(BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
}
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayStatefulEntry(builder: (BuildContext context, StateSetter setter) {
setState = setter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: shouldShowChild ? LayoutBuilder(builder: layoutBuilder) : null,
);
}),
],
),
),
);
expect(tester.takeException(), isNull);
setState(() { shouldShowChild = true; });
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('works in a LayoutBuilder 3', (WidgetTester tester) async {
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
late StateSetter setState;
bool shouldShowChild = false;
Widget layoutBuilder(BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
);
}
controller1.hide();
controller2.hide();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayStatefulEntry(builder: (BuildContext context, StateSetter setter) {
setState = setter;
// The Positioned widget ensures there's no relayout boundary
// between the Overlay and the OverlayPortal.
return Positioned(
top: 0,
left: 0,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: shouldShowChild ? LayoutBuilder(builder: layoutBuilder) : null,
),
);
}),
],
),
),
);
controller1.show();
controller2.show();
setState(() { shouldShowChild = true; });
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('throws when no Overlay', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: SizedBox.square(
dimension: 50,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(),
),
),
),
);
expect(
tester.takeException().toString(),
startsWith(
'No Overlay widget found.\n'
'OverlayPortal widgets require an Overlay widget ancestor.\n'
'An overlay lets widgets float on top of other widget children.\n'
'To introduce an Overlay widget, you can either directly include one, or use a widget '
'that contains an Overlay itself, such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.\n'
'The specific widget that could not find a Overlay ancestor was:\n'
),
);
});
testWidgets('widget is laid out before overlay child', (WidgetTester tester) async {
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
int layoutCount = 0;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return _ManyRelayoutBoundaries(levels: 50, child: Builder(builder: (BuildContext context) {
return OverlayPortal(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
verifyOverlayChildReadyForLayout(widgetKey);
layoutCount += 1;
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}));
}
),
],
),
),
);
expect(layoutCount, 1);
// Make the widget's render object dirty and verifies in the LayoutBuilder's
// callback that the widget's render object is already laid out.
final RenderObject renderChild1 = widgetKey.currentContext!.findRenderObject()!;
renderChild1.markNeedsLayout();
// Dirty both render subtree branches.
childBox.markNeedsLayout();
rebuildLayoutBuilderSubtree(overlayChildBox);
// Make sure childBox's depth is greater than that of the overlay
// child, and childBox's parent isn't dirty (childBox is a dirty relayout
// boundary).
expect(widgetKey.currentContext!.findRenderObject()!.depth, lessThan(overlayChildBox.depth));
await tester.pump();
expect(layoutCount, 2);
verifyTreeIsClean();
});
testWidgets('adding/removing overlay child does not redirty overlay more than once', (WidgetTester tester) async {
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget');
final GlobalKey overlayKey = GlobalKey(debugLabel: 'overlay');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final _RenderLayoutCounter overlayLayoutCounter = _RenderLayoutCounter();
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
int layoutCount = 0;
controller1.hide();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
key: overlayKey,
initialEntries: <OverlayEntry>[
// Overlay.performLayout will call layoutCounter.layout.
overlayEntry1 = OverlayEntry(builder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: overlayLayoutCounter)),
overlayEntry2 = OverlayEntry(
builder: (BuildContext context) {
return _ManyRelayoutBoundaries(levels: 50, child: Builder(builder: (BuildContext context) {
return OverlayPortal(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount += 1;
expect(tester.renderObject(find.byType(Overlay)).debugNeedsLayout, false);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}));
}
),
],
),
),
);
expect(layoutCount, 0);
expect(overlayLayoutCounter.layoutCount, 1);
verifyTreeIsClean();
// Add overlay child.
controller1.show();
await tester.pump();
expect(layoutCount, 1);
expect(overlayLayoutCounter.layoutCount, 1);
verifyTreeIsClean();
// Remove the overlay child.
controller1.hide();
await tester.pump();
expect(layoutCount, 1);
expect(overlayLayoutCounter.layoutCount, 1);
verifyTreeIsClean();
});
group('Adding/removing overlay child causes repaint', () {
// Regression test for https://github.com/flutter/flutter/issues/134656.
const Key childKey = Key('child');
final OverlayEntry overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return RepaintBoundary(
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const SizedBox(),
child: const SizedBox(key: childKey),
),
);
},
);
final Widget widget = Directionality(
key: GlobalKey(debugLabel: 'key'),
textDirection: TextDirection.ltr,
child: Overlay(initialEntries: <OverlayEntry>[overlayEntry]),
);
tearDown(overlayEntry.remove);
tearDownAll(overlayEntry.dispose);
testWidgets('Adding child', (WidgetTester tester) async {
controller1.hide();
await tester.pumpWidget(widget);
final RenderBox renderTheater = tester.renderObject<RenderBox>(find.byType(Overlay));
final RenderBox renderChild = tester.renderObject<RenderBox>(find.byKey(childKey));
assert(!renderTheater.debugNeedsPaint);
assert(!renderChild.debugNeedsPaint);
controller1.show();
await tester.pump(null, EnginePhase.layout);
expect(renderTheater.debugNeedsPaint, isTrue);
expect(renderChild.debugNeedsPaint, isFalse);
// Discard the dirty render tree.
await tester.pumpWidget(const SizedBox());
});
testWidgets('Removing child', (WidgetTester tester) async {
controller1.show();
await tester.pumpWidget(widget);
final RenderBox renderTheater = tester.renderObject<RenderBox>(find.byType(Overlay));
final RenderBox renderChild = tester.renderObject<RenderBox>(find.byKey(childKey));
assert(!renderTheater.debugNeedsPaint);
assert(!renderChild.debugNeedsPaint);
controller1.hide();
await tester.pump(null, EnginePhase.layout);
expect(renderTheater.debugNeedsPaint, isTrue);
expect(renderChild.debugNeedsPaint, isFalse);
// Discard the dirty render tree.
await tester.pumpWidget(const SizedBox());
});
});
testWidgets('Adding/Removing OverlayPortal in LayoutBuilder during layout', (WidgetTester tester) async {
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget');
final GlobalKey overlayKey = GlobalKey(debugLabel: 'overlay');
controller1.hide();
late StateSetter setState;
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
Size size = Size.zero;
final Widget overlayPortal = OverlayPortal(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Placeholder(),
child: const Placeholder(),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
key: overlayKey,
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return Center(
child: SizedBox.fromSize(
size: size,
child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
// This layout callback adds/removes an OverlayPortal during layout.
return constraints.maxHeight > 0 ? overlayPortal : const SizedBox();
}),
),
);
}
);
}
),
],
),
),
);
controller1.show();
await tester.pump();
expect(tester.takeException(), isNull);
// Adds the OverlayPortal from within a LayoutBuilder, in a layout callback.
setState(() { size = const Size(300, 300); });
await tester.pump();
expect(tester.takeException(), isNull);
// Removes the OverlayPortal from within a LayoutBuilder, in a layout callback.
setState(() { size = Size.zero; });
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('Change overlay constraints', (WidgetTester tester) async {
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget outer');
final GlobalKey overlayKey = GlobalKey(debugLabel: 'overlay');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final _RenderLayoutCounter overlayLayoutCounter = _RenderLayoutCounter();
int layoutCount = 0;
late StateSetter setState;
double dimension = 100;
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox.square(
dimension: dimension,
child: Overlay(
key: overlayKey,
initialEntries: <OverlayEntry>[
// Overlay.performLayout calls layoutCounter.layout.
_buildOverlayEntry((BuildContext context) => WidgetToRenderBoxAdapter(renderBox: overlayLayoutCounter)),
_buildOverlayEntry((BuildContext outerEntryContext) {
return Center(
child: _ManyRelayoutBoundaries(
levels: 50,
child: Builder(builder: (BuildContext context) {
return OverlayPortal(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount += 1;
// Both overlays need to be clean at this point.
expect(
tester.renderObjectList(find.byType(Overlay)),
everyElement(wrapMatcher((RenderObject object) => !object.debugNeedsLayout || object.debugDoingThisLayout)),
);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}),
),
);
}
),
],
),
),
),
);
}
),
);
expect(layoutCount, 1);
expect(overlayLayoutCounter.layoutCount, 1);
expect(childBox.size, const Size.square(50));
expect(overlayChildBox.size, const Size.square(100));
verifyTreeIsClean();
// The incoming constraints changed.
setState(() {
dimension = 150;
});
await tester.pump();
expect(childBox.size, const Size.square(50));
expect(overlayChildBox.size, const Size.square(150));
expect(layoutCount, 2);
expect(overlayLayoutCounter.layoutCount, 2);
verifyTreeIsClean();
});
testWidgets('Can target the root overlay',
(WidgetTester tester) async {
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget outer');
final GlobalKey rootOverlayKey = GlobalKey(debugLabel: 'root overlay');
final GlobalKey localOverlayKey = GlobalKey(debugLabel: 'local overlay');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final _RenderLayoutCounter overlayLayoutCounter = _RenderLayoutCounter();
int layoutCount = 0;
OverlayPortal Function({ Widget? child, required OverlayPortalController controller, Key? key, required WidgetBuilder overlayChildBuilder, }) constructorToUse = OverlayPortal.new;
late StateSetter setState;
// This tree has 3 nested Overlays.
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
key: rootOverlayKey,
initialEntries: <OverlayEntry>[
_buildOverlayEntry((BuildContext context) {
return Overlay(
initialEntries: <OverlayEntry>[
_buildOverlayEntry((BuildContext context) {
return Overlay(
key: localOverlayKey,
initialEntries: <OverlayEntry>[
// Overlay.performLayout calls layoutCounter.layout.
_buildOverlayEntry((BuildContext context) => WidgetToRenderBoxAdapter(renderBox: overlayLayoutCounter)),
_buildOverlayEntry((BuildContext outerEntryContext) {
return Center(
child: Builder(builder: (BuildContext context) {
return constructorToUse(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount += 1;
// Both overlays need to be clean at this point.
expect(
tester.renderObjectList(find.byType(Overlay)),
everyElement(wrapMatcher((RenderObject object) => !object.debugNeedsLayout || object.debugDoingThisLayout)),
);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}),
);
}),
],
);
}),
],
);
}),
],
),
),
);
}
),
);
expect(layoutCount, 1);
expect(overlayLayoutCounter.layoutCount, 1);
expect(_ancestorRenderTheaters(overlayChildBox).length, 3);
verifyTreeIsClean();
// Now targets the root overlay.
setState(() { constructorToUse = OverlayPortal.targetsRootOverlay; });
await tester.pump();
expect(layoutCount, 2);
expect(overlayLayoutCounter.layoutCount, 1);
expect(_ancestorRenderTheaters(overlayChildBox).single, tester.renderObject(find.byKey(rootOverlayKey)));
verifyTreeIsClean();
});
group('GlobalKey Reparenting', () {
testWidgets('child is laid out before overlay child after OverlayEntry shuffle', (WidgetTester tester) async {
int layoutCount = 0;
final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final OverlayEntry overlayEntry1 = OverlayEntry(builder: (BuildContext context) {
return _ManyRelayoutBoundaries(
levels: 50,
child: Builder(builder: (BuildContext context) {
return OverlayPortal(
key: widgetKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount += 1;
verifyOverlayChildReadyForLayout(widgetKey);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}),
);
});
addTearDown(() => overlayEntry1..remove()..dispose());
final OverlayEntry overlayEntry2 = OverlayEntry(builder: (BuildContext context) => const Placeholder());
addTearDown(() => overlayEntry2..remove()..dispose());
final OverlayEntry overlayEntry3 = OverlayEntry(builder: (BuildContext context) => const Placeholder());
addTearDown(() => overlayEntry3..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[overlayEntry1, overlayEntry2, overlayEntry3],
),
),
);
expect(layoutCount, 1);
verifyTreeIsClean();
widgetKey.currentContext!.findRenderObject()!.markNeedsLayout();
childBox.markNeedsLayout();
rebuildLayoutBuilderSubtree(overlayChildBox);
// Make sure childBox's depth is greater than that of the overlay child.
expect(
widgetKey.currentContext!.findRenderObject()!.depth,
lessThan(overlayChildBox.depth),
);
tester.state<OverlayState>(find.byType(Overlay)).rearrange(<OverlayEntry>[overlayEntry3, overlayEntry2, overlayEntry1]);
await tester.pump();
expect(layoutCount, 2);
expect(widgetKey.currentContext!.findRenderObject()!.depth, lessThan(overlayChildBox.depth));
verifyTreeIsClean();
});
testWidgets('widget is laid out before overlay child after reparenting', (WidgetTester tester) async {
final GlobalKey targetGlobalKey = GlobalKey(debugLabel: 'target widget');
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
late StateSetter setState1, setState2;
bool targetMovedToOverlayEntry3 = false;
int layoutCount1 = 0;
int layoutCount2 = 0;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
_buildOverlayEntry((BuildContext context) {
return _ManyRelayoutBoundaries(
levels: 50,
child: StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return targetMovedToOverlayEntry3 ? const SizedBox() : OverlayPortal(
key: targetGlobalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount1 += 1;
verifyOverlayChildReadyForLayout(targetGlobalKey);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}),
);
}),
_buildOverlayEntry((BuildContext context) => const Placeholder()),
_buildOverlayEntry((BuildContext context) {
return SizedBox(
child: StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return !targetMovedToOverlayEntry3 ? const SizedBox() : OverlayPortal(
key: targetGlobalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
layoutCount2 += 1;
verifyOverlayChildReadyForLayout(targetGlobalKey);
return WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
});
},
child: WidgetToRenderBoxAdapter(renderBox: childBox),
);
}),
);
}),
],
),
),
);
expect(layoutCount1, 1);
expect(layoutCount2, 0);
targetGlobalKey.currentContext!.findRenderObject()!.markNeedsLayout();
childBox.markNeedsLayout();
rebuildLayoutBuilderSubtree(overlayChildBox);
setState1(() {});
setState2(() {});
targetMovedToOverlayEntry3 = true;
await tester.pump();
expect(
targetGlobalKey.currentContext!.findRenderObject()!.depth,
lessThan(overlayChildBox.depth),
);
verifyTreeIsClean();
expect(layoutCount1, 1);
expect(layoutCount2, 1);
});
testWidgets('Swap child and overlayChild', (WidgetTester tester) async {
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
late StateSetter setState;
bool swapChildAndRemoteChild = false;
// WidgetToRenderBoxAdapter has its own builtin GlobalKey.
final Widget child1 = WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
final Widget child2 = WidgetToRenderBoxAdapter(renderBox: childBox);
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(builder: (BuildContext context) {
return _ManyRelayoutBoundaries(
levels: 50,
child: StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => swapChildAndRemoteChild ? child1 : child2,
child: swapChildAndRemoteChild ? child2 : child1,
);
}),
);
}),
],
),
),
);
setState(() { swapChildAndRemoteChild = true; });
await tester.pump();
verifyTreeIsClean();
});
testWidgets('forgetChild', (WidgetTester tester) async {
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
late StateSetter setState1;
late StateSetter setState2;
bool takeChildren = false;
// WidgetToRenderBoxAdapter has its own builtin GlobalKey.
final Widget child1 = WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
final Widget child2 = WidgetToRenderBoxAdapter(renderBox: childBox);
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
controller1.hide();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayEntry(builder: (BuildContext context) {
return StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => child2,
child: takeChildren ? child1 : null,
);
});
}),
overlayEntry2 = OverlayEntry(builder: (BuildContext context) {
return _ManyRelayoutBoundaries(
levels: 50,
child: StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => child1,
child: takeChildren ? null : child2,
);
}),
);
}),
],
),
),
);
controller1.show();
controller2.hide();
setState2(() { takeChildren = true; });
setState1(() { });
await tester.pump();
verifyTreeIsClean();
});
testWidgets('Nested overlay children: swap inner and outer', (WidgetTester tester) async {
final GlobalKey outerKey = GlobalKey(debugLabel: 'Original Outer Widget');
final GlobalKey innerKey = GlobalKey(debugLabel: 'Original Inner Widget');
final RenderBox child1Box = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox child2Box = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
addTearDown(overlayChildBox.dispose);
late StateSetter setState;
bool swapped = false;
// WidgetToRenderBoxAdapter has its own builtin GlobalKey.
final Widget child1 = WidgetToRenderBoxAdapter(renderBox: child1Box);
final Widget child2 = WidgetToRenderBoxAdapter(renderBox: child2Box);
final Widget child3 = WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(builder: (BuildContext context) {
return StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return OverlayPortal(
key: swapped ? outerKey : innerKey,
controller: swapped ? controller2 : controller1,
overlayChildBuilder: (BuildContext context) {
return OverlayPortal(
key: swapped ? innerKey : outerKey,
controller: swapped ? controller1 : controller2,
overlayChildBuilder: (BuildContext context) {
return OverlayPortal(
controller: OverlayPortalController(),
overlayChildBuilder: (BuildContext context) => child3,
);
},
child: child2,
);
},
child: child1,
);
});
}),
],
),
),
);
setState(() { swapped = true; });
await tester.pump();
verifyTreeIsClean();
});
testWidgets('Paint order', (WidgetTester tester) async {
final GlobalKey outerKey = GlobalKey(debugLabel: 'Original Outer Widget');
final GlobalKey innerKey = GlobalKey(debugLabel: 'Original Inner Widget');
late StateSetter setState;
const Widget child1 = _PaintOrder();
const Widget child2 = _PaintOrder();
const Widget child3 = _PaintOrder();
const Widget child4 = _PaintOrder();
controller1.show();
controller2.show();
controller3.show();
controller4.show();
// Expected Order child1 -> innerKey -> child4.
Widget widget = Column(
children: <Widget>[
OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => child1,
),
OverlayPortal(
key: outerKey,
controller: controller2,
overlayChildBuilder: (BuildContext context) {
return OverlayPortal(
key: innerKey,
controller: controller3,
overlayChildBuilder: (BuildContext context) => child3,
child: child2,
);
},
),
OverlayPortal(
controller: controller4,
overlayChildBuilder: (BuildContext context) => child4,
),
],
);
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(builder: (BuildContext context) {
return StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return widget;
});
}),
],
),
),
);
expect(_PaintOrder.paintOrder,
<Widget>[
child1,
child2,
child3,
child4,
],
);
_PaintOrder.paintOrder.clear();
// Swap the nested OverlayPortal.
widget = Column(
children: <Widget>[
OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => child1,
),
OverlayPortal(
key: innerKey,
controller: controller3,
overlayChildBuilder: (BuildContext context) {
return OverlayPortal(
key: outerKey,
controller: controller2,
overlayChildBuilder: (BuildContext context) => child3,
child: child2,
);
},
),
OverlayPortal(
controller: controller4,
overlayChildBuilder: (BuildContext context) => child4,
),
],
);
setState(() {});
await tester.pump();
expect(_PaintOrder.paintOrder,
<Widget>[
child1,
child3,
child2,
child4,
],
);
});
group('Swapping', () {
StateSetter? setState1, setState2;
bool swapped = false;
void setState({ required bool newValue }) {
swapped = newValue;
setState1?.call(() {});
setState2?.call(() {});
}
tearDown(() {
swapped = false;
setState1 = null;
setState2 = null;
});
testWidgets('between OverlayEntry & overlayChild', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
// WidgetToRenderBoxAdapter is keyed by the render box.
return WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1);
}),
overlayEntry2 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
child: const SizedBox(),
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
testWidgets('between OverlayEntry & overlayChild, featuring LayoutBuilder', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1);
}),
overlayEntry2 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
child: const SizedBox(),
);
}
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
testWidgets('between overlayChild & overlayChild', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return OverlayPortal(
// WidgetToRenderBoxAdapter is keyed by the render box.
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1),
child: const SizedBox(),
);
}),
overlayEntry2 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
child: const SizedBox(),
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
testWidgets('between overlayChild & overlayChild, featuring LayoutBuilder', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1),
child: const SizedBox(),
);
}
);
}),
overlayEntry2 = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState2 = stateSetter;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
child: const SizedBox(),
);
}
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
testWidgets('between child & overlayChild', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return OverlayPortal(
// WidgetToRenderBoxAdapter is keyed by the render box.
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1),
child: WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
testWidgets('between child & overlayChild, featuring LayoutBuilder', (WidgetTester tester) async {
final _RenderLayoutCounter counter1 = _RenderLayoutCounter();
final _RenderLayoutCounter counter2 = _RenderLayoutCounter();
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayStatefulEntry(builder: (BuildContext context, StateSetter stateSetter) {
setState1 = stateSetter;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverlayPortal(
// WidgetToRenderBoxAdapter is keyed by the render box.
controller: controller1,
overlayChildBuilder: (BuildContext context) => WidgetToRenderBoxAdapter(renderBox: swapped ? counter2 : counter1),
child: WidgetToRenderBoxAdapter(renderBox: swapped ? counter1 : counter2),
);
}
);
}),
],
),
),
);
expect(counter1.layoutCount, 1);
expect(counter2.layoutCount, 1);
setState(newValue: true);
await tester.pump();
expect(counter1.layoutCount, 2);
expect(counter2.layoutCount, 2);
setState(newValue: false);
await tester.pump();
expect(counter1.layoutCount, 3);
expect(counter2.layoutCount, 3);
});
});
testWidgets('Safe to move the overlay child to a different Overlay and remove the old Overlay', (WidgetTester tester) async {
controller1.show();
final GlobalKey key = GlobalKey(debugLabel: 'key');
final GlobalKey oldOverlayKey = GlobalKey(debugLabel: 'old overlay');
final GlobalKey newOverlayKey = GlobalKey(debugLabel: 'new overlay');
final GlobalKey overlayChildKey = GlobalKey(debugLabel: 'overlay child key');
late final OverlayEntry overlayEntry1;
addTearDown(() => overlayEntry1..remove()..dispose());
late final OverlayEntry overlayEntry2;
addTearDown(() => overlayEntry2..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
key: oldOverlayKey,
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
key: key,
controller: controller1,
overlayChildBuilder: (BuildContext context) => SizedBox(key: overlayChildKey),
child: const SizedBox(),
);
},
),
],
),
),
);
expect(find.byKey(overlayChildKey), findsOneWidget);
expect(find.byKey(newOverlayKey), findsNothing);
expect(find.byKey(oldOverlayKey), findsOneWidget);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
key: newOverlayKey,
initialEntries: <OverlayEntry>[
overlayEntry2 = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
key: key,
controller: controller1,
overlayChildBuilder: (BuildContext context) => SizedBox(key: overlayChildKey),
child: const SizedBox(),
);
},
),
],
),
),
);
expect(tester.takeException(), isNull);
expect(find.byKey(overlayChildKey), findsOneWidget);
expect(find.byKey(newOverlayKey), findsOneWidget);
expect(find.byKey(oldOverlayKey), findsNothing);
});
});
group('Paint order', () {
testWidgets('show bringsToTop', (WidgetTester tester) async {
controller1.hide();
const _PaintOrder child1 = _PaintOrder();
const _PaintOrder child2 = _PaintOrder();
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(builder: (BuildContext context) {
return Column(
children: <Widget>[
OverlayPortal(controller: controller1, overlayChildBuilder: (BuildContext context) => child1),
OverlayPortal(controller: controller2, overlayChildBuilder: (BuildContext context) => child2),
],
);
}),
],
),
),
);
// Only child2 is visible.
expect(
_PaintOrder.paintOrder,
<_PaintOrder>[
child2,
],
);
_PaintOrder.paintOrder.clear();
controller1.show();
await tester.pump();
expect(
_PaintOrder.paintOrder,
<_PaintOrder>[
child2,
child1,
],
);
_PaintOrder.paintOrder.clear();
controller2.show();
await tester.pump();
expect(
_PaintOrder.paintOrder,
<_PaintOrder>[
child1,
child2,
],
);
_PaintOrder.paintOrder.clear();
controller2.hide();
controller1.hide();
await tester.pump();
expect(
_PaintOrder.paintOrder,
isEmpty,
);
});
testWidgets('Paint order does not change after global key reparenting', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
late StateSetter setState;
bool reparented = false;
// WidgetToRenderBoxAdapter has its own builtin GlobalKey.
final RenderBox child1Box = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox child2Box = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final Widget child1 = WidgetToRenderBoxAdapter(renderBox: child1Box);
final Widget child2 = WidgetToRenderBoxAdapter(renderBox: child2Box);
final Widget overlayPortal1 = OverlayPortal(
key: key,
controller: controller1,
overlayChildBuilder: (BuildContext context) => child1,
child: const SizedBox(),
);
final Widget overlayPortal2 = OverlayPortal(
controller: controller2,
overlayChildBuilder: (BuildContext context) => child2,
child: const SizedBox(),
);
late final OverlayEntry overlayEntry;
addTearDown(() => overlayEntry..remove()..dispose());
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(builder: (BuildContext context) {
return Column(
children: <Widget>[
StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return reparented ? SizedBox(child: overlayPortal1) : overlayPortal1;
}),
overlayPortal2,
],
);
}),
],
),
),
);
final RenderObject theater = tester.renderObject<RenderObject>(find.byType(Overlay));
final List<RenderObject> childrenVisited = <RenderObject>[];
theater.visitChildren(childrenVisited.add);
expect(childrenVisited.length, 3);
expect(childrenVisited, containsAllInOrder(<RenderObject>[child1Box.parent!, child2Box.parent!]));
childrenVisited.clear();
setState(() { reparented = true; });
await tester.pump();
theater.visitChildren(childrenVisited.add);
// The child list stays the same.
expect(childrenVisited, containsAllInOrder(<RenderObject>[child1Box.parent!, child2Box.parent!]));
});
});
group('Semantics', () {
testWidgets('ordering and transform', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final double rowOriginY = TestSemantics.fullScreen.height - 10;
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) {
return DefaultTextStyle(
style: const TextStyle(fontSize: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Semantics(
container: true,
explicitChildNodes: true,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Positioned(left: 0.0, top: 0.0, child: Text('BBBB')),
child: const Text('A'),
),
),
const Text('CC'),
],
),
);
},
),
],
),
);
await tester.pumpWidget(widget);
final Matrix4 node1Transform = Matrix4.identity()
..scale(3.0, 3.0, 1.0)
..translate(0.0, TestSemantics.fullScreen.height - 10.0);
final Matrix4 node4Transform = node1Transform.clone()..translate(10.0);
final TestSemantics expected = TestSemantics.root(children: <TestSemantics>[
TestSemantics(
id: 1,
rect: Offset.zero & const Size(10, 10),
transform: node1Transform,
children: <TestSemantics>[
TestSemantics(id: 2, label: 'A', rect: Offset.zero & const Size(10, 10)),
// The crossAxisAlignment is set to `end`. The size of node 1 is 30 x 10.
TestSemantics(
id: 3,
label: 'BBBB',
rect: Offset.zero & const Size(40, 10),
transform: Matrix4.translationValues(0, -rowOriginY, 0),
),
],
),
TestSemantics(
id: 4,
label: 'CC',
rect: Offset.zero & const Size(20, 10),
transform: node4Transform
),
]);
expect(semantics, hasSemantics(expected));
semantics.dispose();
});
testWidgets('OverlayPortal overlay child clipping', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) {
return DefaultTextStyle(
style: const TextStyle(fontSize: 10),
child: ListView(
children: <Widget>[
// Clips OverlayPortal, making it only half visible.
SizedBox(height: TestSemantics.fullScreen.height - 5),
Semantics(
container: true,
explicitChildNodes: true,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return Positioned(
left: 0,
right: 0,
top: 0,
height: 10,
child: ListView(
children: const <Widget>[
SizedBox(height: 3), // Clips B so it's only 7 pixels tall.
Text('B'),
],
),
);
},
child: const Text('A'),
),
),
],
),
);
},
),
],
),
);
await tester.pumpWidget(widget);
final SemanticsNode clippedOverlayPortal = semantics.nodesWith(label: 'A').single;
final SemanticsNode clippedOverlayChild = semantics.nodesWith(label: 'B').single;
expect(clippedOverlayPortal.rect, Offset.zero & const Size(800, 5));
expect(clippedOverlayChild.rect, Offset.zero & const Size(800, 7));
expect(clippedOverlayPortal.transform, isNull);
// The parent SemanticsNode is created by the ListView.
expect(clippedOverlayChild.transform, Matrix4.translationValues(0.0, 3.0, 0.0));
semantics.dispose();
});
testWidgets("OverlayPortal's semantics node is hidden", (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) {
return DefaultTextStyle(
style: const TextStyle(fontSize: 10),
child: ListView(
children: <Widget>[
// Clips OverlayPortal, making it completely invisible.
SizedBox(height: TestSemantics.fullScreen.height),
Semantics(
container: true,
explicitChildNodes: true,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return const Positioned(
left: 0,
top: 0,
child: Text('B'),
);
},
child: const Text('A'),
),
),
],
),
);
},
),
],
),
);
await tester.pumpWidget(widget);
final SemanticsNode clippedOverlayPortal = semantics.nodesWith(label: 'A').single;
final SemanticsNode clippedOverlayChild = semantics.nodesWith(label: 'B').single;
expect(clippedOverlayPortal.rect, Offset.zero & const Size(800, 10));
expect(clippedOverlayChild.rect, Offset.zero & const Size(10, 10));
expect(clippedOverlayPortal.transform, isNull);
// The parent SemanticsNode is created by OverlayPortal.
expect(clippedOverlayChild.transform, Matrix4.translationValues(0.0, -600.0, 0.0));
semantics.dispose();
});
testWidgets("OverlayPortal's semantics node is dropped but the element is kept alive", (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final ScrollController controller = ScrollController(initialScrollOffset: 10);
addTearDown(controller.dispose);
late final OverlayEntry entry;
addTearDown(() { entry.remove(); entry.dispose(); });
final Widget widget = Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) {
return DefaultTextStyle(
style: const TextStyle(fontSize: 10),
child: ListView(
controller: controller,
cacheExtent: 0,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
addSemanticIndexes: false,
children: <Widget>[
// Clips OverlayPortal, making it completely invisible.
SizedBox(height: TestSemantics.fullScreen.height),
KeepAlive(
keepAlive: true,
child: Semantics(
container: true,
explicitChildNodes: true,
child: OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) {
return const Positioned(
left: 0,
top: 0,
child: Text('B'),
);
},
child: const Text('A'),
),
),
),
],
),
);
},
),
],
),
);
await tester.pumpWidget(widget);
expect(semantics.nodesWith(label: 'A'), isNotEmpty);
expect(semantics.nodesWith(label: 'B'), isNotEmpty);
controller.jumpTo(0);
await tester.pump();
expect(semantics.nodesWith(label: 'A'), isEmpty);
expect(semantics.nodesWith(label: 'B'), isEmpty);
semantics.dispose();
final RenderObject overlayRenderObject = tester.renderObject(find.byType(Overlay));
// Paints 'B' but not both 'A' and 'B'.
expect(overlayRenderObject, paints..paragraph());
expect(overlayRenderObject, isNot(paints..paragraph()..paragraph()));
});
});
}
class OverlayStatefulEntry extends OverlayEntry {
OverlayStatefulEntry({
required StatefulWidgetBuilder builder,
}) : super(builder: (BuildContext context) => StatefulBuilder(builder: builder));
}
class _RenderLayoutCounter extends RenderProxyBox {
int layoutCount = 0;
bool _parentDoingLayout = false;
@override
void layout(Constraints constraints, {bool parentUsesSize = false}) {
assert(!_parentDoingLayout);
_parentDoingLayout = true;
layoutCount += 1;
super.layout(constraints, parentUsesSize: parentUsesSize);
_parentDoingLayout = false;
}
@override
void performLayout() {
super.performLayout();
if (!_parentDoingLayout) {
layoutCount += 1;
}
}
}
/// This helper makes leak tracker forgiving the entry is not disposed.
OverlayEntry _buildOverlayEntry(WidgetBuilder builder) => OverlayEntry(builder: builder);
class _PaintOrder extends SingleChildRenderObjectWidget {
const _PaintOrder();
static List<_PaintOrder> paintOrder = <_PaintOrder>[];
void onPaint() => paintOrder.add(this);
@override
_RenderPaintRecorder createRenderObject(BuildContext context) => _RenderPaintRecorder()..onPaint = onPaint;
@override
void updateRenderObject(BuildContext context, _RenderPaintRecorder renderObject) => renderObject.onPaint = onPaint;
}
class _RenderPaintRecorder extends RenderProxyBox {
VoidCallback? onPaint;
@override
void paint(PaintingContext context, Offset offset) {
onPaint?.call();
super.paint(context, offset);
}
}