diff --git a/packages/flutter/lib/src/rendering/stack.dart b/packages/flutter/lib/src/rendering/stack.dart index 502588b095..c72c1d146e 100644 --- a/packages/flutter/lib/src/rendering/stack.dart +++ b/packages/flutter/lib/src/rendering/stack.dart @@ -425,7 +425,8 @@ class RenderStack extends RenderBox } } - double _getIntrinsicDimension(double mainChildSizeGetter(RenderBox child)) { + /// Helper function for calculating the intrinsics metrics of a Stack. + static double getIntrinsicDimension(RenderBox firstChild, double mainChildSizeGetter(RenderBox child)) { double extent = 0.0; RenderBox child = firstChild; while (child != null) { @@ -440,22 +441,22 @@ class RenderStack extends RenderBox @override double computeMinIntrinsicWidth(double height) { - return _getIntrinsicDimension((RenderBox child) => child.getMinIntrinsicWidth(height)); + return getIntrinsicDimension(firstChild, (RenderBox child) => child.getMinIntrinsicWidth(height)); } @override double computeMaxIntrinsicWidth(double height) { - return _getIntrinsicDimension((RenderBox child) => child.getMaxIntrinsicWidth(height)); + return getIntrinsicDimension(firstChild, (RenderBox child) => child.getMaxIntrinsicWidth(height)); } @override double computeMinIntrinsicHeight(double width) { - return _getIntrinsicDimension((RenderBox child) => child.getMinIntrinsicHeight(width)); + return getIntrinsicDimension(firstChild, (RenderBox child) => child.getMinIntrinsicHeight(width)); } @override double computeMaxIntrinsicHeight(double width) { - return _getIntrinsicDimension((RenderBox child) => child.getMaxIntrinsicHeight(width)); + return getIntrinsicDimension(firstChild, (RenderBox child) => child.getMaxIntrinsicHeight(width)); } @override @@ -463,6 +464,57 @@ class RenderStack extends RenderBox return defaultComputeDistanceToHighestActualBaseline(baseline); } + /// Lays out the positioned `child` according to `alignment` within a Stack of `size`. + /// + /// Returns true when the child has visual overflow. + static bool layoutPositionedChild(RenderBox child, StackParentData childParentData, Size size, Alignment alignment) { + assert(childParentData.isPositioned); + assert(child.parentData == childParentData); + + bool hasVisualOverflow = false; + BoxConstraints childConstraints = const BoxConstraints(); + + if (childParentData.left != null && childParentData.right != null) + childConstraints = childConstraints.tighten(width: size.width - childParentData.right - childParentData.left); + else if (childParentData.width != null) + childConstraints = childConstraints.tighten(width: childParentData.width); + + if (childParentData.top != null && childParentData.bottom != null) + childConstraints = childConstraints.tighten(height: size.height - childParentData.bottom - childParentData.top); + else if (childParentData.height != null) + childConstraints = childConstraints.tighten(height: childParentData.height); + + child.layout(childConstraints, parentUsesSize: true); + + double x; + if (childParentData.left != null) { + x = childParentData.left; + } else if (childParentData.right != null) { + x = size.width - childParentData.right - child.size.width; + } else { + x = alignment.alongOffset(size - child.size as Offset).dx; + } + + if (x < 0.0 || x + child.size.width > size.width) + hasVisualOverflow = true; + + double y; + if (childParentData.top != null) { + y = childParentData.top; + } else if (childParentData.bottom != null) { + y = size.height - childParentData.bottom - child.size.height; + } else { + y = alignment.alongOffset(size - child.size as Offset).dy; + } + + if (y < 0.0 || y + child.size.height > size.height) + hasVisualOverflow = true; + + childParentData.offset = Offset(x, y); + + return hasVisualOverflow; + } + @override void performLayout() { _resolve(); @@ -527,45 +579,7 @@ class RenderStack extends RenderBox if (!childParentData.isPositioned) { childParentData.offset = _resolvedAlignment.alongOffset(size - child.size as Offset); } else { - BoxConstraints childConstraints = const BoxConstraints(); - - if (childParentData.left != null && childParentData.right != null) - childConstraints = childConstraints.tighten(width: size.width - childParentData.right - childParentData.left); - else if (childParentData.width != null) - childConstraints = childConstraints.tighten(width: childParentData.width); - - if (childParentData.top != null && childParentData.bottom != null) - childConstraints = childConstraints.tighten(height: size.height - childParentData.bottom - childParentData.top); - else if (childParentData.height != null) - childConstraints = childConstraints.tighten(height: childParentData.height); - - child.layout(childConstraints, parentUsesSize: true); - - double x; - if (childParentData.left != null) { - x = childParentData.left; - } else if (childParentData.right != null) { - x = size.width - childParentData.right - child.size.width; - } else { - x = _resolvedAlignment.alongOffset(size - child.size as Offset).dx; - } - - if (x < 0.0 || x + child.size.width > size.width) - _hasVisualOverflow = true; - - double y; - if (childParentData.top != null) { - y = childParentData.top; - } else if (childParentData.bottom != null) { - y = size.height - childParentData.bottom - child.size.height; - } else { - y = _resolvedAlignment.alongOffset(size - child.size as Offset).dy; - } - - if (y < 0.0 || y + child.size.height > size.height) - _hasVisualOverflow = true; - - childParentData.offset = Offset(x, y); + _hasVisualOverflow = layoutPositionedChild(child, childParentData, size, _resolvedAlignment) || _hasVisualOverflow; } assert(child.parentData == childParentData); diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index ec6bc13c30..8f9a8f5697 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -4,13 +4,13 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'basic.dart'; -import 'debug.dart'; import 'framework.dart'; import 'ticker_provider.dart'; @@ -115,7 +115,7 @@ class OverlayEntry { } OverlayState _overlay; - final GlobalKey<_OverlayEntryState> _key = GlobalKey<_OverlayEntryState>(); + final GlobalKey<_OverlayEntryWidgetState> _key = GlobalKey<_OverlayEntryWidgetState>(); /// Remove this entry from the overlay. /// @@ -152,21 +152,30 @@ class OverlayEntry { String toString() => '${describeIdentity(this)}(opaque: $opaque; maintainState: $maintainState)'; } -class _OverlayEntry extends StatefulWidget { - _OverlayEntry(this.entry) - : assert(entry != null), - super(key: entry._key); +class _OverlayEntryWidget extends StatefulWidget { + const _OverlayEntryWidget({ + @required Key key, + @required this.entry, + this.tickerEnabled = true, + }) : assert(key != null), + assert(entry != null), + assert(tickerEnabled != null), + super(key: key); final OverlayEntry entry; + final bool tickerEnabled; @override - _OverlayEntryState createState() => _OverlayEntryState(); + _OverlayEntryWidgetState createState() => _OverlayEntryWidgetState(); } -class _OverlayEntryState extends State<_OverlayEntry> { +class _OverlayEntryWidgetState extends State<_OverlayEntryWidget> { @override Widget build(BuildContext context) { - return widget.entry.builder(context); + return TickerMode( + enabled: widget.tickerEnabled, + child: widget.entry.builder(context), + ); } void _markNeedsBuild() { @@ -442,28 +451,32 @@ class OverlayState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - // These lists are filled backwards. For the offstage children that - // does not matter since they aren't rendered, but for the onstage - // children we reverse the list below before adding it to the tree. - final List onstageChildren = []; - final List offstageChildren = []; + // This list is filled backwards and then reversed below before + // it is added to the tree. + final List children = []; bool onstage = true; + int onstageCount = 0; for (int i = _entries.length - 1; i >= 0; i -= 1) { final OverlayEntry entry = _entries[i]; if (onstage) { - onstageChildren.add(_OverlayEntry(entry)); + onstageCount += 1; + children.add(_OverlayEntryWidget( + key: entry._key, + entry: entry, + )); if (entry.opaque) onstage = false; } else if (entry.maintainState) { - offstageChildren.add(TickerMode(enabled: false, child: _OverlayEntry(entry))); + children.add(_OverlayEntryWidget( + key: entry._key, + entry: entry, + tickerEnabled: false, + )); } } return _Theatre( - onstage: Stack( - fit: StackFit.expand, - children: onstageChildren.reversed.toList(growable: false), - ), - offstage: offstageChildren, + skipCount: children.length - onstageCount, + children: children.reversed.toList(growable: false), ); } @@ -476,36 +489,50 @@ class OverlayState extends State with TickerProviderStateMixin { } } -/// A widget that has one [onstage] child which is visible, and one or more -/// [offstage] widgets which are kept alive, and are built, but are not laid out -/// or painted. +/// Special version of a [Stack], that doesn't layout and render the first +/// [skipCount] children. /// -/// The onstage widget must be a [Stack]. -/// -/// For convenience, it is legal to use [Positioned] widgets around the offstage -/// widgets. -class _Theatre extends RenderObjectWidget { +/// The first [skipCount] children are considered "offstage". +class _Theatre extends MultiChildRenderObjectWidget { _Theatre({ - this.onstage, - @required this.offstage, - }) : assert(offstage != null), - assert(!offstage.any((Widget child) => child == null)); + Key key, + this.skipCount = 0, + List children = const [], + }) : assert(skipCount != null), + assert(skipCount >= 0), + assert(children != null), + assert(children.length >= skipCount), + super(key: key, children: children); - final Stack onstage; - - final List offstage; + final int skipCount; @override _TheatreElement createElement() => _TheatreElement(this); @override - _RenderTheatre createRenderObject(BuildContext context) => _RenderTheatre(); + _RenderTheatre createRenderObject(BuildContext context) { + return _RenderTheatre( + skipCount: skipCount, + textDirection: Directionality.of(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderTheatre renderObject) { + renderObject + ..skipCount = skipCount + ..textDirection = Directionality.of(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('skipCount', skipCount)); + } } -class _TheatreElement extends RenderObjectElement { - _TheatreElement(_Theatre widget) - : assert(!debugChildrenHaveDuplicateKeys(widget, widget.offstage)), - super(widget); +class _TheatreElement extends MultiChildRenderObjectElement { + _TheatreElement(_Theatre widget) : super(widget); @override _Theatre get widget => super.widget as _Theatre; @@ -513,186 +540,268 @@ class _TheatreElement extends RenderObjectElement { @override _RenderTheatre get renderObject => super.renderObject as _RenderTheatre; - Element _onstage; - static final Object _onstageSlot = Object(); - - List _offstage; - final Set _forgottenOffstageChildren = HashSet(); - - @override - void insertChildRenderObject(RenderBox child, dynamic slot) { - assert(renderObject.debugValidateChild(child)); - if (slot == _onstageSlot) { - assert(child is RenderStack); - renderObject.child = child as RenderStack; - } else { - assert(slot == null || slot is Element); - renderObject.insert(child, after: slot?.renderObject as RenderBox); - } - } - - @override - void moveChildRenderObject(RenderBox child, dynamic slot) { - if (slot == _onstageSlot) { - renderObject.remove(child); - assert(child is RenderStack); - renderObject.child = child as RenderStack; - } else { - assert(slot == null || slot is Element); - if (renderObject.child == child) { - renderObject.child = null; - renderObject.insert(child, after: slot?.renderObject as RenderBox); - } else { - renderObject.move(child, after: slot?.renderObject as RenderBox); - } - } - } - - @override - void removeChildRenderObject(RenderBox child) { - if (renderObject.child == child) { - renderObject.child = null; - } else { - renderObject.remove(child); - } - } - - @override - void visitChildren(ElementVisitor visitor) { - if (_onstage != null) - visitor(_onstage); - for (final Element child in _offstage) { - if (!_forgottenOffstageChildren.contains(child)) - visitor(child); - } - } - @override void debugVisitOnstageChildren(ElementVisitor visitor) { - if (_onstage != null) - visitor(_onstage); - } - - @override - bool forgetChild(Element child) { - if (child == _onstage) { - _onstage = null; - } else { - assert(_offstage.contains(child)); - assert(!_forgottenOffstageChildren.contains(child)); - _forgottenOffstageChildren.add(child); - } - return true; - } - - @override - void mount(Element parent, dynamic newSlot) { - super.mount(parent, newSlot); - _onstage = updateChild(_onstage, widget.onstage, _onstageSlot); - _offstage = List(widget.offstage.length); - Element previousChild; - for (int i = 0; i < _offstage.length; i += 1) { - final Element newChild = inflateWidget(widget.offstage[i], previousChild); - _offstage[i] = newChild; - previousChild = newChild; - } - } - - @override - void update(_Theatre newWidget) { - super.update(newWidget); - assert(widget == newWidget); - _onstage = updateChild(_onstage, widget.onstage, _onstageSlot); - _offstage = updateChildren(_offstage, widget.offstage, forgottenChildren: _forgottenOffstageChildren); - _forgottenOffstageChildren.clear(); + assert(children.length >= widget.skipCount); + children.skip(widget.skipCount).forEach(visitor); } } -// A render object which lays out and paints one subtree while keeping a list -// of other subtrees alive but not laid out or painted (the "zombie" children). -// -// The subtree that is laid out and painted must be a [RenderStack]. -// -// This class uses [StackParentData] objects for its parent data so that the -// children of its primary subtree's stack can be moved to this object's list -// of zombie children without changing their parent data objects. -class _RenderTheatre extends RenderBox - with RenderObjectWithChildMixin, RenderProxyBoxMixin, - ContainerRenderObjectMixin { +class _RenderTheatre extends RenderBox with ContainerRenderObjectMixin { + _RenderTheatre({ + List children, + @required TextDirection textDirection, + int skipCount = 0, + }) : assert(skipCount != null), + assert(skipCount >= 0), + assert(textDirection != null), + _textDirection = textDirection, + _skipCount = skipCount { + addAll(children); + } + + bool _hasVisualOverflow = false; @override - void setupParentData(RenderObject child) { + void setupParentData(RenderBox child) { if (child.parentData is! StackParentData) child.parentData = StackParentData(); } - // Because both RenderObjectWithChildMixin and ContainerRenderObjectMixin - // define redepthChildren, visitChildren and debugDescribeChildren and don't - // call super, we have to define them again here to make sure the work of both - // is done. - // - // We chose to put ContainerRenderObjectMixin last in the inheritance chain so - // that we can call super to hit its more complex definitions of - // redepthChildren and visitChildren, and then duplicate the more trivial - // definition from RenderObjectWithChildMixin inline in our version here. - // - // This code duplication is suboptimal. - // TODO(ianh): Replace this with a better solution once https://github.com/dart-lang/sdk/issues/27100 is fixed - // - // For debugDescribeChildren we just roll our own because otherwise the line - // drawings won't really work as well. + Alignment _resolvedAlignment; + + void _resolve() { + if (_resolvedAlignment != null) + return; + _resolvedAlignment = AlignmentDirectional.topStart.resolve(textDirection); + } + + void _markNeedResolution() { + _resolvedAlignment = null; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) + return; + _textDirection = value; + _markNeedResolution(); + } + + int get skipCount => _skipCount; + int _skipCount; + set skipCount(int value) { + assert(value != null); + if (_skipCount != value) { + _skipCount = value; + markNeedsLayout(); + } + } + + RenderBox get _firstOnstageChild { + if (skipCount == super.childCount) { + return null; + } + RenderBox child = super.firstChild; + for (int toSkip = skipCount; toSkip > 0; toSkip--) { + final StackParentData childParentData = child.parentData as StackParentData; + child = childParentData.nextSibling; + assert(child != null); + } + return child; + } + + RenderBox get _lastOnstageChild => skipCount == super.childCount ? null : lastChild; + + int get _onstageChildCount => childCount - skipCount; @override - void redepthChildren() { - if (child != null) - redepthChild(child); - super.redepthChildren(); + double computeMinIntrinsicWidth(double height) { + return RenderStack.getIntrinsicDimension(_firstOnstageChild, (RenderBox child) => child.getMinIntrinsicWidth(height)); } @override - void visitChildren(RenderObjectVisitor visitor) { - if (child != null) + double computeMaxIntrinsicWidth(double height) { + return RenderStack.getIntrinsicDimension(_firstOnstageChild, (RenderBox child) => child.getMaxIntrinsicWidth(height)); + } + + @override + double computeMinIntrinsicHeight(double width) { + return RenderStack.getIntrinsicDimension(_firstOnstageChild, (RenderBox child) => child.getMinIntrinsicHeight(width)); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return RenderStack.getIntrinsicDimension(_firstOnstageChild, (RenderBox child) => child.getMaxIntrinsicHeight(width)); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + assert(!debugNeedsLayout); + double result; + RenderBox child = _firstOnstageChild; + while (child != null) { + assert(!child.debugNeedsLayout); + final StackParentData childParentData = child.parentData as StackParentData; + double candidate = child.getDistanceToActualBaseline(baseline); + if (candidate != null) { + candidate += childParentData.offset.dy; + if (result != null) { + result = math.min(result, candidate); + } else { + result = candidate; + } + } + child = childParentData.nextSibling; + } + return result; + } + + @override + bool get sizedByParent => true; + + @override + void performResize() { + size = constraints.biggest; + assert(size.isFinite); + } + + @override + void performLayout() { + _hasVisualOverflow = false; + + if (_onstageChildCount == 0) { + return; + } + + _resolve(); + assert(_resolvedAlignment != null); + + // Same BoxConstraints as used by RenderStack for StackFit.expand. + final BoxConstraints nonPositionedConstraints = BoxConstraints.tight(constraints.biggest); + + RenderBox child = _firstOnstageChild; + while (child != null) { + final StackParentData childParentData = child.parentData as StackParentData; + + if (!childParentData.isPositioned) { + child.layout(nonPositionedConstraints, parentUsesSize: true); + childParentData.offset = _resolvedAlignment.alongOffset(size - child.size as Offset); + } else { + _hasVisualOverflow = RenderStack.layoutPositionedChild(child, childParentData, size, _resolvedAlignment) || _hasVisualOverflow; + } + + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, { Offset position }) { + RenderBox child = _lastOnstageChild; + for (int i = 0; i < _onstageChildCount; i++) { + assert(child != null); + final StackParentData childParentData = child.parentData as StackParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) + return true; + child = childParentData.previousSibling; + } + return false; + } + + @protected + void paintStack(PaintingContext context, Offset offset) { + RenderBox child = _firstOnstageChild; + while (child != null) { + final StackParentData childParentData = child.parentData as StackParentData; + context.paintChild(child, childParentData.offset + offset); + child = childParentData.nextSibling; + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_hasVisualOverflow) { + context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintStack); + } else { + paintStack(context, offset); + } + } + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + RenderBox child = _firstOnstageChild; + while (child != null) { visitor(child); - super.visitChildren(visitor); + final StackParentData childParentData = child.parentData as StackParentData; + child = childParentData.nextSibling; + } + } + + @override + Rect describeApproximatePaintClip(RenderObject child) => _hasVisualOverflow ? Offset.zero & size : null; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('skipCount', skipCount)); + properties.add(EnumProperty('textDirection', textDirection)); } @override List debugDescribeChildren() { - final List children = [ - if (child != null) child.toDiagnosticsNode(name: 'onstage'), - ]; + final List offstageChildren = []; + final List onstageChildren = []; - if (firstChild != null) { - RenderBox child = firstChild; + int count = 1; + bool onstage = false; + RenderBox child = firstChild; + final RenderBox firstOnstageChild = _firstOnstageChild; + while (child != null) { + if (child == firstOnstageChild) { + onstage = true; + count = 1; + } - int count = 1; - while (true) { - children.add( + if (onstage) { + onstageChildren.add( + child.toDiagnosticsNode( + name: 'onstage $count', + ), + ); + } else { + offstageChildren.add( child.toDiagnosticsNode( name: 'offstage $count', style: DiagnosticsTreeStyle.offstage, ), ); - if (child == lastChild) - break; - final StackParentData childParentData = child.parentData as StackParentData; - child = childParentData.nextSibling; - count += 1; } - } else { - children.add( + + final StackParentData childParentData = child.parentData as StackParentData; + child = childParentData.nextSibling; + count += 1; + } + + return [ + ...onstageChildren, + if (offstageChildren.isNotEmpty) + ...offstageChildren + else DiagnosticsNode.message( 'no offstage children', style: DiagnosticsTreeStyle.offstage, ), - ); - } - return children; - } - - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - if (child != null) - visitor(child); + ]; } } diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart index dda551e102..f65d4b00ec 100644 --- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -72,12 +72,9 @@ CupertinoPageScaffold scaffoldForNavBar(Widget navBar) { } Finder flying(WidgetTester tester, Finder finder) { - final RenderObjectWithChildMixin theater = - tester.renderObject(find.byType(Overlay)); - final RenderStack theaterStack = theater.child; + final ContainerRenderObjectMixin theater = tester.renderObject(find.byType(Overlay)); final Finder lastOverlayFinder = find.byElementPredicate((Element element) { - return element is RenderObjectElement && - element.renderObject == theaterStack.lastChild; + return element is RenderObjectElement && element.renderObject == theater.lastChild; }); assert( diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index f612b6a781..3c8c53a7d7 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -132,8 +132,8 @@ void main() { ' Offstage\n' ' _ModalScopeStatus\n' ' _ModalScope-[LabeledGlobalKey<_ModalScopeState>#969b7]\n' - ' _OverlayEntry-[LabeledGlobalKey<_OverlayEntryState>#7a3ae]\n' - ' Stack\n' + ' TickerMode\n' + ' _OverlayEntryWidget-[LabeledGlobalKey<_OverlayEntryWidgetState>#545d0]\n' ' _Theatre\n' ' Overlay-[LabeledGlobalKey#31a52]\n' ' _FocusMarker\n' diff --git a/packages/flutter/test/material/stepper_test.dart b/packages/flutter/test/material/stepper_test.dart index 13ee688642..bd6266b6d6 100644 --- a/packages/flutter/test/material/stepper_test.dart +++ b/packages/flutter/test/material/stepper_test.dart @@ -529,12 +529,13 @@ void main() { // which will change depending on where the test is run. expect(lines.length, greaterThan(7)); expect( - lines.take(8).join('\n'), + lines.take(9).join('\n'), equalsIgnoringHashCodes( '══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞════════════════════════\n' 'The following assertion was thrown building Stepper(dirty,\n' - 'dependencies: [_LocalizationsScope-[GlobalKey#00000]], state:\n' - '_StepperState#00000):\n' + 'dependencies: [TickerMode,\n' + '_LocalizationsScope-[GlobalKey#6b31b]], state:\n' + '_StepperState#1bf00):\n' 'Steppers must not be nested.\n' 'The material specification advises that one should avoid\n' 'embedding steppers within steppers.\n' diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 5522c1179d..aace92daf5 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -1186,6 +1186,33 @@ void main() { expect(find.byKey(const ValueKey('/A/B')), findsNothing); // popped expect(find.byKey(const ValueKey('/C')), findsOneWidget); }); + + testWidgets('Pushing opaque Route does not rebuild routes below', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/45797. + + final GlobalKey navigator = GlobalKey(); + final Key bottomRoute = UniqueKey(); + final Key topRoute = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + routes: { + '/' : (BuildContext context) => StatefulTestWidget(key: bottomRoute), + '/a': (BuildContext context) => StatefulTestWidget(key: topRoute), + }, + ), + ); + expect(tester.state(find.byKey(bottomRoute)).rebuildCount, 1); + + navigator.currentState.pushNamed('/a'); + await tester.pumpAndSettle(); + + // Bottom route is offstage and did not rebuild. + expect(find.byKey(bottomRoute), findsNothing); + expect(tester.state(find.byKey(bottomRoute, skipOffstage: false)).rebuildCount, 1); + + expect(tester.state(find.byKey(topRoute)).rebuildCount, 1); + }); } class NoAnimationPageRoute extends PageRouteBuilder { @@ -1199,3 +1226,20 @@ class NoAnimationPageRoute extends PageRouteBuilder { return super.createAnimationController()..value = 1.0; } } + +class StatefulTestWidget extends StatefulWidget { + const StatefulTestWidget({Key key}) : super(key: key); + + @override + State createState() => StatefulTestState(); +} + +class StatefulTestState extends State { + int rebuildCount = 0; + + @override + Widget build(BuildContext context) { + rebuildCount += 1; + return Container(); + } +} diff --git a/packages/flutter/test/widgets/overlay_test.dart b/packages/flutter/test/widgets/overlay_test.dart index 0453fcd861..21b60d831f 100644 --- a/packages/flutter/test/widgets/overlay_test.dart +++ b/packages/flutter/test/widgets/overlay_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; +import 'semantics_tester.dart'; + void main() { testWidgets('OverflowEntries context contains Overlay', (WidgetTester tester) async { final GlobalKey overlayKey = GlobalKey(); @@ -25,6 +27,9 @@ void main() { return Container(); }, ), + OverlayEntry( + builder: (BuildContext context) => Container(), + ) ], ), ), @@ -36,36 +41,42 @@ void main() { expect( theater.toStringDeep(minLevel: DiagnosticLevel.info), equalsIgnoringHashCodes( - '_RenderTheatre#f5cf2\n' - ' │ parentData: \n' - ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' │ size: Size(800.0, 600.0)\n' - ' │\n' - ' ├─onstage: RenderStack#39819\n' - ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' - ' ╎ │ size)\n' - ' ╎ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' ╎ │ size: Size(800.0, 600.0)\n' - ' ╎ │ alignment: AlignmentDirectional.topStart\n' - ' ╎ │ textDirection: ltr\n' - ' ╎ │ fit: expand\n' - ' ╎ │ overflow: clip\n' - ' ╎ │\n' - ' ╎ └─child 1: RenderLimitedBox#d1448\n' - ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' - ' ╎ │ size)\n' - ' ╎ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' ╎ │ size: Size(800.0, 600.0)\n' - ' ╎ │ maxWidth: 0.0\n' - ' ╎ │ maxHeight: 0.0\n' - ' ╎ │\n' - ' ╎ └─child: RenderConstrainedBox#e8b87\n' - ' ╎ parentData: (can use size)\n' - ' ╎ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' ╎ size: Size(800.0, 600.0)\n' - ' ╎ additionalConstraints: BoxConstraints(biggest)\n' - ' ╎\n' - ' └╌no offstage children\n' + '_RenderTheatre#744c9\n' + ' │ parentData: \n' + ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' │ size: Size(800.0, 600.0)\n' + ' │ skipCount: 0\n' + ' │ textDirection: ltr\n' + ' │\n' + ' ├─onstage 1: RenderLimitedBox#bb803\n' + ' │ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' + ' │ │ size)\n' + ' │ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' │ │ size: Size(800.0, 600.0)\n' + ' │ │ maxWidth: 0.0\n' + ' │ │ maxHeight: 0.0\n' + ' │ │\n' + ' │ └─child: RenderConstrainedBox#62707\n' + ' │ parentData: (can use size)\n' + ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' │ size: Size(800.0, 600.0)\n' + ' │ additionalConstraints: BoxConstraints(biggest)\n' + ' │\n' + ' ├─onstage 2: RenderLimitedBox#af5f1\n' + ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' + ' ╎ │ size)\n' + ' ╎ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' ╎ │ size: Size(800.0, 600.0)\n' + ' ╎ │ maxWidth: 0.0\n' + ' ╎ │ maxHeight: 0.0\n' + ' ╎ │\n' + ' ╎ └─child: RenderConstrainedBox#69c48\n' + ' ╎ parentData: (can use size)\n' + ' ╎ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' ╎ size: Size(800.0, 600.0)\n' + ' ╎ additionalConstraints: BoxConstraints(biggest)\n' + ' ╎\n' + ' └╌no offstage children\n' ), ); }); @@ -103,60 +114,52 @@ void main() { expect( theater.toStringDeep(minLevel: DiagnosticLevel.info), equalsIgnoringHashCodes( - '_RenderTheatre#b22a8\n' + '_RenderTheatre#385b3\n' ' │ parentData: \n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ size: Size(800.0, 600.0)\n' + ' │ skipCount: 2\n' + ' │ textDirection: ltr\n' ' │\n' - ' ├─onstage: RenderStack#eab87\n' + ' ├─onstage 1: RenderLimitedBox#0a77a\n' ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' ' ╎ │ size)\n' ' ╎ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' ╎ │ size: Size(800.0, 600.0)\n' - ' ╎ │ alignment: AlignmentDirectional.topStart\n' - ' ╎ │ textDirection: ltr\n' - ' ╎ │ fit: expand\n' - ' ╎ │ overflow: clip\n' + ' ╎ │ maxWidth: 0.0\n' + ' ╎ │ maxHeight: 0.0\n' ' ╎ │\n' - ' ╎ └─child 1: RenderLimitedBox#ca15b\n' - ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0) (can use\n' - ' ╎ │ size)\n' - ' ╎ │ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' ╎ │ size: Size(800.0, 600.0)\n' - ' ╎ │ maxWidth: 0.0\n' - ' ╎ │ maxHeight: 0.0\n' - ' ╎ │\n' - ' ╎ └─child: RenderConstrainedBox#dffe5\n' - ' ╎ parentData: (can use size)\n' - ' ╎ constraints: BoxConstraints(w=800.0, h=600.0)\n' - ' ╎ size: Size(800.0, 600.0)\n' - ' ╎ additionalConstraints: BoxConstraints(biggest)\n' + ' ╎ └─child: RenderConstrainedBox#21f3a\n' + ' ╎ parentData: (can use size)\n' + ' ╎ constraints: BoxConstraints(w=800.0, h=600.0)\n' + ' ╎ size: Size(800.0, 600.0)\n' + ' ╎ additionalConstraints: BoxConstraints(biggest)\n' ' ╎\n' - ' ╎╌offstage 1: RenderLimitedBox#b6f09 NEEDS-LAYOUT NEEDS-PAINT\n' + ' ╎╌offstage 1: RenderLimitedBox#62c8c NEEDS-LAYOUT NEEDS-PAINT\n' ' ╎ │ parentData: not positioned; offset=Offset(0.0, 0.0)\n' ' ╎ │ constraints: MISSING\n' ' ╎ │ size: MISSING\n' ' ╎ │ maxWidth: 0.0\n' ' ╎ │ maxHeight: 0.0\n' ' ╎ │\n' - ' ╎ └─child: RenderConstrainedBox#5a057 NEEDS-LAYOUT NEEDS-PAINT\n' + ' ╎ └─child: RenderConstrainedBox#425fa NEEDS-LAYOUT NEEDS-PAINT\n' ' ╎ parentData: \n' ' ╎ constraints: MISSING\n' ' ╎ size: MISSING\n' ' ╎ additionalConstraints: BoxConstraints(biggest)\n' ' ╎\n' - ' └╌offstage 2: RenderLimitedBox#f689e NEEDS-LAYOUT NEEDS-PAINT\n' + ' └╌offstage 2: RenderLimitedBox#03ae2 NEEDS-LAYOUT NEEDS-PAINT\n' ' │ parentData: not positioned; offset=Offset(0.0, 0.0)\n' ' │ constraints: MISSING\n' ' │ size: MISSING\n' ' │ maxWidth: 0.0\n' ' │ maxHeight: 0.0\n' ' │\n' - ' └─child: RenderConstrainedBox#c15f0 NEEDS-LAYOUT NEEDS-PAINT\n' + ' └─child: RenderConstrainedBox#b4d48 NEEDS-LAYOUT NEEDS-PAINT\n' ' parentData: \n' ' constraints: MISSING\n' ' size: MISSING\n' - ' additionalConstraints: BoxConstraints(biggest)\n' + ' additionalConstraints: BoxConstraints(biggest)\n', ), ); }); @@ -698,4 +701,261 @@ void main() { expect(find.byKey(root), findsNothing); expect(find.byKey(top), findsOneWidget); }); + + testWidgets('OverlayEntries do not rebuild when opaqueness changes', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/45797. + + final GlobalKey overlayKey = GlobalKey(); + final Key bottom = UniqueKey(); + final Key middle = UniqueKey(); + final Key top = UniqueKey(); + final Widget bottomWidget = StatefulTestWidget(key: bottom); + final Widget middleWidget = StatefulTestWidget(key: middle); + final Widget topWidget = StatefulTestWidget(key: top); + + final OverlayEntry bottomEntry = OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return bottomWidget; + }, + ); + final OverlayEntry middleEntry = OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return middleWidget; + }, + ); + final OverlayEntry topEntry = OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return topWidget; + }, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + key: overlayKey, + initialEntries: [ + bottomEntry, + middleEntry, + topEntry, + ], + ), + ), + ); + + // All widgets are onstage. + expect(tester.state(find.byKey(bottom)).rebuildCount, 1); + expect(tester.state(find.byKey(middle)).rebuildCount, 1); + expect(tester.state(find.byKey(top)).rebuildCount, 1); + + middleEntry.opaque = true; + await tester.pump(); + + // Bottom widget is offstage and did not rebuild. + expect(find.byKey(bottom), findsNothing); + expect(tester.state(find.byKey(bottom, skipOffstage: false)).rebuildCount, 1); + expect(tester.state(find.byKey(middle)).rebuildCount, 1); + expect(tester.state(find.byKey(top)).rebuildCount, 1); + }); + + testWidgets('OverlayEntries do not rebuild when opaque entry is added', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/45797. + + final GlobalKey overlayKey = GlobalKey(); + final Key bottom = UniqueKey(); + final Key middle = UniqueKey(); + final Key top = UniqueKey(); + final Widget bottomWidget = StatefulTestWidget(key: bottom); + final Widget middleWidget = StatefulTestWidget(key: middle); + final Widget topWidget = StatefulTestWidget(key: top); + + final OverlayEntry bottomEntry = OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return bottomWidget; + }, + ); + final OverlayEntry middleEntry = OverlayEntry( + opaque: true, + maintainState: true, + builder: (BuildContext context) { + return middleWidget; + }, + ); + final OverlayEntry topEntry = OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return topWidget; + }, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + key: overlayKey, + initialEntries: [ + bottomEntry, + topEntry, + ], + ), + ), + ); + + // Both widgets are onstage. + expect(tester.state(find.byKey(bottom)).rebuildCount, 1); + expect(tester.state(find.byKey(top)).rebuildCount, 1); + + overlayKey.currentState.rearrange([ + bottomEntry, middleEntry, topEntry, + ]); + await tester.pump(); + + // Bottom widget is offstage and did not rebuild. + expect(find.byKey(bottom), findsNothing); + expect(tester.state(find.byKey(bottom, skipOffstage: false)).rebuildCount, 1); + expect(tester.state(find.byKey(middle)).rebuildCount, 1); + expect(tester.state(find.byKey(top)).rebuildCount, 1); + }); + + testWidgets('entries below opaque entries are ignored for hit testing', (WidgetTester tester) async { + final GlobalKey overlayKey = GlobalKey(); + int bottomTapCount = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + key: overlayKey, + initialEntries: [ + OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + bottomTapCount++; + }, + ); + }, + ), + ], + ), + ), + ); + + expect(bottomTapCount, 0); + await tester.tap(find.byKey(overlayKey)); + expect(bottomTapCount, 1); + + overlayKey.currentState.insert(OverlayEntry( + maintainState: true, + opaque: true, + builder: (BuildContext context) { + return Container(); + }, + )); + await tester.pump(); + + // Bottom is offstage and does not receive tap events. + expect(find.byType(GestureDetector), findsNothing); + expect(find.byType(GestureDetector, skipOffstage: false), findsOneWidget); + await tester.tap(find.byKey(overlayKey)); + expect(bottomTapCount, 1); + + int topTapCount = 0; + overlayKey.currentState.insert(OverlayEntry( + maintainState: true, + opaque: true, + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + topTapCount++; + }, + ); + }, + )); + await tester.pump(); + + expect(topTapCount, 0); + await tester.tap(find.byKey(overlayKey)); + expect(topTapCount, 1); + expect(bottomTapCount, 1); + }); + + testWidgets('Semantics of entries below opaque entries are ignored', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final GlobalKey overlayKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + key: overlayKey, + initialEntries: [ + OverlayEntry( + maintainState: true, + builder: (BuildContext context) { + return const Text('bottom'); + }, + ), + OverlayEntry( + maintainState: true, + opaque: true, + builder: (BuildContext context) { + return const Text('top'); + }, + ), + ], + ), + ), + ); + expect(find.text('bottom'), findsNothing); + expect(find.text('bottom', skipOffstage: false), findsOneWidget); + expect(find.text('top'), findsOneWidget); + expect(semantics, includesNodeWith(label: 'top')); + expect(semantics, isNot(includesNodeWith(label: 'bottom'))); + + semantics.dispose(); + }); + + testWidgets('Can used Positioned within OverlayEntry', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return const Positioned( + left: 145, + top: 123, + child: Text('positioned child'), + ); + }, + ), + ], + ), + ), + ); + + expect(tester.getTopLeft(find.text('positioned child')), const Offset(145, 123)); + }); +} + +class StatefulTestWidget extends StatefulWidget { + const StatefulTestWidget({Key key}) : super(key: key); + + @override + State createState() => StatefulTestState(); +} + +class StatefulTestState extends State { + int rebuildCount = 0; + + @override + Widget build(BuildContext context) { + rebuildCount += 1; + return Container(); + } }