From ea6bf4706a64532fbadf483367c99bcf8ae2c531 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Mon, 29 Aug 2016 11:28:37 -0700 Subject: [PATCH] Fix the losing of state when pushing opaque routes (#5624) Fixes https://github.com/flutter/flutter/issues/5283 Other changes in this patch: Rename OffStage to Offstage. Fixes https://github.com/flutter/flutter/issues/5378 Add a lot of docs. Some minor punctuation and whitespace fixes. --- packages/flutter/lib/src/material/page.dart | 10 +- .../flutter/lib/src/rendering/binding.dart | 48 ++- packages/flutter/lib/src/rendering/layer.dart | 2 +- .../flutter/lib/src/rendering/object.dart | 65 ++-- .../flutter/lib/src/rendering/proxy_box.dart | 16 +- .../lib/src/rendering/shifted_box.dart | 12 +- packages/flutter/lib/src/rendering/stack.dart | 8 +- packages/flutter/lib/src/widgets/basic.dart | 32 +- packages/flutter/lib/src/widgets/binding.dart | 61 +++- packages/flutter/lib/src/widgets/debug.dart | 6 + .../flutter/lib/src/widgets/framework.dart | 330 ++++++++++++++++-- .../flutter/lib/src/widgets/navigator.dart | 6 +- packages/flutter/lib/src/widgets/overlay.dart | 302 +++++++++++++++- packages/flutter/lib/src/widgets/routes.dart | 25 +- .../test/material/popup_menu_test.dart | 8 +- .../flutter/test/rendering/offstage_test.dart | 2 +- packages/flutter/test/widget/heroes_test.dart | 51 +-- .../flutter/test/widget/navigator_test.dart | 38 +- .../widget/page_forward_transitions_test.dart | 35 +- .../test/widget/page_transitions_test.dart | 34 +- .../widget/remember_scroll_position_test.dart | 191 +++++----- .../test/widget/reparent_state_test.dart | 41 +++ .../flutter_test/lib/src/all_elements.dart | 25 +- packages/flutter_test/lib/src/controller.dart | 2 +- packages/flutter_test/lib/src/finders.dart | 97 +++-- packages/flutter_test/lib/src/matchers.dart | 63 ++-- .../flutter_test/lib/src/widget_tester.dart | 2 +- .../flutter_test/test/widget_tester_test.dart | 22 +- 28 files changed, 1199 insertions(+), 335 deletions(-) diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index 4f02ed1a46f..2b6372be170 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -148,12 +148,17 @@ class _CupertinoBackGestureController extends NavigationGestureController { /// /// [MaterialApp] creates material page routes for entries in the /// [MaterialApp.routes] map. +/// +/// By default, when a modal route is replaced by another, the previous route +/// remains in memory. To free all the resources when this is not necessary, set +/// [maintainState] to false. class MaterialPageRoute extends PageRoute { /// Creates a page route for use in a material design app. MaterialPageRoute({ this.builder, Completer completer, - RouteSettings settings: const RouteSettings() + RouteSettings settings: const RouteSettings(), + this.maintainState: true, }) : super(completer: completer, settings: settings) { assert(builder != null); assert(opaque); @@ -162,6 +167,9 @@ class MaterialPageRoute extends PageRoute { /// Builds the primary contents of the route. final WidgetBuilder builder; + @override + final bool maintainState; + @override Duration get transitionDuration => const Duration(milliseconds: 300); diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index b20184dea7c..fd59a4d9b9a 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -148,7 +148,53 @@ abstract class RendererBinding extends BindingBase implements SchedulerBinding, /// Pump the rendering pipeline to generate a frame. /// - /// Called automatically by the engine when it is time to lay out and paint a frame. + /// This method is called by [handleBeginFrame], which itself is called + /// automatically by the engine when when it is time to lay out and paint a + /// frame. + /// + /// Each frame consists of the following phases: + /// + /// 1. The animation phase: The [handleBeginFrame] method, which is registered + /// with [ui.window.onBeginFrame], invokes all the transient frame callbacks + /// registered with [scheduleFrameCallback] and [addFrameCallback], in + /// registration order. This includes all the [Ticker] instances that are + /// driving [AnimationController] objects, which means all of the active + /// [Animation] objects tick at this point. + /// + /// [handleBeginFrame] then invokes all the persistent frame callbacks, of which + /// the most notable is this method, [beginFrame], which proceeds as follows: + /// + /// 2. The layout phase: All the dirty [RenderObject]s in the system are laid + /// out (see [RenderObject.performLayout]). See [RenderObject.markNeedsLayout] + /// for further details on marking an object dirty for layout. + /// + /// 3. The compositing bits phase: The compositing bits on any dirty + /// [RenderObject] objects are updated. See + /// [RenderObject.markNeedsCompositingBitsUpdate]. + /// + /// 4. The paint phase: All the dirty [RenderObject]s in the system are + /// repainted (see [RenderObject.paint]). This generates the [Layer] tree. See + /// [RenderObject.markNeedsPaint] for further details on marking an object + /// dirty for paint. + /// + /// 5. The compositing phase: The layer tree is turned into a [ui.Scene] and + /// sent to the GPU. + /// + /// 6. The semantics phase: All the dirty [RenderObject]s in the system have + /// their semantics updated (see [RenderObject.semanticAnnotator]). This + /// generates the [SemanticsNode] tree. See + /// [RenderObject.markNeedsSemanticsUpdate] for further details on marking an + /// object dirty for semantics. + /// + /// For more details on steps 2-6, see [PipelineOwner]. + /// + /// 7. The finalization phase: After [beginFrame] returns, [handleBeginFrame] + /// then invokes post-frame callbacks (registered with [addPostFrameCallback]. + /// + /// Some bindings (for example, the [WidgetsBinding]) add extra steps to this + /// list. + // + // When editing the above, also update widgets/binding.dart's copy. void beginFrame() { assert(renderView != null); pipelineOwner.flushLayout(); diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart index 95583b87d69..2ac319e4ef3 100644 --- a/packages/flutter/lib/src/rendering/layer.dart +++ b/packages/flutter/lib/src/rendering/layer.dart @@ -12,7 +12,7 @@ import 'package:mojo_services/mojo/gfx/composition/scene_token.mojom.dart' as mo import 'debug.dart'; -/// A composited layer +/// A composited layer. /// /// During painting, the render tree generates a tree of composited layers that /// are uploaded into the engine and displayed by the compositor. This class is diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 293d27e3917..4cb7e8af805 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -1956,26 +1956,24 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// Mark this node as needing an update to its semantics /// description. /// - /// If the change did not involve a removal or addition of - /// semantics, only the change of semantics (e.g. isChecked changing - /// from true to false, as opposed to isChecked changing from being - /// true to not being changed at all), then you can pass the - /// onlyChanges argument with the value true to reduce the cost. If - /// semantics are being added or removed, more work needs to be done - /// to update the semantics tree. If you pass 'onlyChanges: true' - /// but this node, which previously had a SemanticsNode, no longer - /// has one, or previously did not set any semantics, but now does, - /// or previously had a child that returned annotators, but no - /// longer does, or other such combinations, then you will either - /// assert during the subsequent call to [flushSemantics()] or you - /// will have out-of-date information in the semantics tree. + /// If the change did not involve a removal or addition of semantics, only the + /// change of semantics (e.g. isChecked changing from true to false, as + /// opposed to isChecked changing from being true to not being changed at + /// all), then you can pass the onlyChanges argument with the value true to + /// reduce the cost. If semantics are being added or removed, more work needs + /// to be done to update the semantics tree. If you pass 'onlyChanges: true' + /// but this node, which previously had a SemanticsNode, no longer has one, or + /// previously did not set any semantics, but now does, or previously had a + /// child that returned annotators, but no longer does, or other such + /// combinations, then you will either assert during the subsequent call to + /// [PipelineOwner.flushSemantics()] or you will have out-of-date information + /// in the semantics tree. /// - /// If the geometry might have changed in any way, then again, more - /// work needs to be done to update the semantics tree (to deal with - /// clips). You can pass the noGeometry argument to avoid this work - /// in the case where only the labels or flags changed. If you pass - /// 'noGeometry: true' when the geometry did change, the semantic - /// tree will be out of date. + /// If the geometry might have changed in any way, then again, more work needs + /// to be done to update the semantics tree (to deal with clips). You can pass + /// the noGeometry argument to avoid this work in the case where only the + /// labels or flags changed. If you pass 'noGeometry: true' when the geometry + /// did change, the semantic tree will be out of date. void markNeedsSemanticsUpdate({ bool onlyChanges: false, bool noGeometry: false }) { assert(!attached || !owner._debugDoingSemantics); if ((attached && owner._semanticsOwner == null) || (_needsSemanticsUpdate && onlyChanges && (_needsSemanticsGeometryUpdate || noGeometry))) @@ -2089,31 +2087,30 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { visitChildren(visitor); } - /// Returns functions that will annotate a SemanticsNode with the - /// semantics of this RenderObject. + /// Returns a function that will annotate a [SemanticsNode] with the semantics + /// of this [RenderObject]. /// - /// To annotate a SemanticsNode for this node, return all the - /// annotators provided by the superclass, plus an annotator that - /// adds the annotations. When the behavior of the annotators would + /// To annotate a SemanticsNode for this node, return an annotator that + /// adds the annotations. When the behavior of the annotator would /// change (e.g. the box is now checked rather than unchecked), call - /// [markNeedsSemanticsUpdate()] to indicate to the rendering system + /// [markNeedsSemanticsUpdate] to indicate to the rendering system /// that the semantics tree needs to be rebuilt. /// /// To introduce a new SemanticsNode, set hasSemantics to true for - /// this object. The functions returned by this function will be used + /// this object. The function returned by this function will be used /// to annotate the SemanticsNode for this object. /// /// Semantic annotations are persistent. Values set in one pass will /// still be set in the next pass. Therefore it is important to - /// explicitly set fields to false once they are no longer true -- + /// explicitly set fields to false once they are no longer true; /// setting them to true when they are to be enabled, and not /// setting them at all when they are not, will mean they remain set /// once enabled once and will never get unset. /// - /// If the number of annotators you return will change from zero to - /// non-zero, and hasSemantics isn't true, then the associated call - /// to markNeedsSemanticsUpdate() must not have 'onlyChanges' set, as - /// it is possible that the node should be entirely removed. + /// If the value return will change from null to non-null (or vice versa), and + /// [hasSemantics] isn't true, then the associated call to + /// [markNeedsSemanticsUpdate] must not have `onlyChanges` set, as it is + /// possible that the node should be entirely removed. SemanticAnnotator get semanticAnnotator => null; @@ -2247,6 +2244,12 @@ abstract class RenderObjectWithChildMixin implem _child.detach(); } + @override + void redepthChildren() { + if (_child != null) + redepthChild(_child); + } + @override void visitChildren(RenderObjectVisitor visitor) { if (_child != null) diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 65d2d433cdd..94c3cadd0ba 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -32,7 +32,7 @@ export 'package:flutter/gestures.dart' show /// the proxy box with its child. However, RenderProxyBox is a useful base class /// for render objects that wish to mimic most, but not all, of the properties /// of their child. -class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin { +class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin, RenderProxyBoxMixin { /// Creates a proxy render box. /// /// Proxy render boxes are rarely created directly because they simply proxy @@ -41,7 +41,15 @@ class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin { @override double computeMinIntrinsicWidth(double height) { if (child != null) @@ -2016,9 +2024,9 @@ class RenderIgnorePointer extends RenderProxyBox { /// Lays the child out as if it was in the tree, but without painting anything, /// without making the child available for hit testing, and without taking any /// room in the parent. -class RenderOffStage extends RenderProxyBox { - /// Creates an off-stage render object. - RenderOffStage({ +class RenderOffstage extends RenderProxyBox { + /// Creates an offstage render object. + RenderOffstage({ bool offstage: true, RenderBox child }) : _offstage = offstage, super(child) { diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart index 98ae769e90f..8a864e2aa91 100644 --- a/packages/flutter/lib/src/rendering/shifted_box.dart +++ b/packages/flutter/lib/src/rendering/shifted_box.dart @@ -422,14 +422,14 @@ class RenderPositionedBox extends RenderAligningShiftedBox { /// ignoring the child's dimensions. /// /// For example, if you wanted a box to always render 50 pixels high, regardless -/// of where it was rendered, you would wrap it in a RenderOverflow with -/// minHeight and maxHeight set to 50.0. Generally speaking, to avoid confusing -/// behavior around hit testing, a RenderOverflowBox should usually be wrapped -/// in a RenderClipRect. +/// of where it was rendered, you would wrap it in a +/// RenderConstrainedOverflowBox with minHeight and maxHeight set to 50.0. +/// Generally speaking, to avoid confusing behavior around hit testing, a +/// RenderConstrainedOverflowBox should usually be wrapped in a RenderClipRect. /// -/// The child is positioned at the top left of the box. To position a smaller +/// The child is positioned according to [alignment]. To position a smaller /// child inside a larger parent, use [RenderPositionedBox] and -/// [RenderConstrainedBox] rather than RenderOverflowBox. +/// [RenderConstrainedBox] rather than RenderConstrainedOverflowBox. class RenderConstrainedOverflowBox extends RenderAligningShiftedBox { /// Creates a render object that lets its child overflow itself. RenderConstrainedOverflowBox({ diff --git a/packages/flutter/lib/src/rendering/stack.dart b/packages/flutter/lib/src/rendering/stack.dart index 9dbb4933c40..fa4af041dce 100644 --- a/packages/flutter/lib/src/rendering/stack.dart +++ b/packages/flutter/lib/src/rendering/stack.dart @@ -199,13 +199,13 @@ class StackParentData extends ContainerBoxParentDataMixin { } } -/// Whether overflowing children should be clipped, or their overflows be +/// Whether overflowing children should be clipped, or their overflow be /// visible. enum Overflow { - /// Children's overflows will be visible. + /// Overflowing children will be visible. visible, - /// Children's overflows will be clipped. - clip + /// Overflowing children will be clipped to the bounds of their parent. + clip, } /// Implements the stack layout algorithm diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 88f772f5d6e..430b73e3f8c 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -1077,9 +1077,9 @@ class SizedOverflowBox extends SingleChildRenderObjectWidget { /// A widget that lays the child out as if it was in the tree, but without painting anything, /// without making the child available for hit testing, and without taking any /// room in the parent. -class OffStage extends SingleChildRenderObjectWidget { +class Offstage extends SingleChildRenderObjectWidget { /// Creates a widget that visually hides its child. - OffStage({ Key key, this.offstage: true, Widget child }) + Offstage({ Key key, this.offstage: true, Widget child }) : super(key: key, child: child) { assert(offstage != null); } @@ -1094,10 +1094,10 @@ class OffStage extends SingleChildRenderObjectWidget { final bool offstage; @override - RenderOffStage createRenderObject(BuildContext context) => new RenderOffStage(offstage: offstage); + RenderOffstage createRenderObject(BuildContext context) => new RenderOffstage(offstage: offstage); @override - void updateRenderObject(BuildContext context, RenderOffStage renderObject) { + void updateRenderObject(BuildContext context, RenderOffstage renderObject) { renderObject.offstage = offstage; } @@ -1106,6 +1106,22 @@ class OffStage extends SingleChildRenderObjectWidget { super.debugFillDescription(description); description.add('offstage: $offstage'); } + + @override + _OffstageElement createElement() => new _OffstageElement(this); +} + +class _OffstageElement extends SingleChildRenderObjectElement { + _OffstageElement(Offstage widget) : super(widget); + + @override + Offstage get widget => super.widget; + + @override + void visitChildrenForSemantics(ElementVisitor visitor) { + if (!widget.offstage) + super.visitChildrenForSemantics(visitor); + } } /// A widget that attempts to size the child to a specific aspect ratio. @@ -2646,6 +2662,14 @@ class IgnorePointer extends SingleChildRenderObjectWidget { ..ignoring = ignoring ..ignoringSemantics = ignoringSemantics; } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('ignoring: $ignoring'); + if (ignoringSemantics != null) + description.add('ignoringSemantics: $ignoringSemantics'); + } } /// A widget that absorbs pointers during hit testing. diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 2e7805f7249..ba93d82d026 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -201,6 +201,63 @@ abstract class WidgetsBinding extends BindingBase implements GestureBinding, Ren bool _buildingDirtyElements = false; + /// Pump the build and rendering pipeline to generate a frame. + /// + /// This method is called by [handleBeginFrame], which itself is called + /// automatically by the engine when when it is time to lay out and paint a + /// frame. + /// + /// Each frame consists of the following phases: + /// + /// 1. The animation phase: The [handleBeginFrame] method, which is registered + /// with [ui.window.onBeginFrame], invokes all the transient frame callbacks + /// registered with [scheduleFrameCallback] and [addFrameCallback], in + /// registration order. This includes all the [Ticker] instances that are + /// driving [AnimationController] objects, which means all of the active + /// [Animation] objects tick at this point. + /// + /// [handleBeginFrame] then invokes all the persistent frame callbacks, of which + /// the most notable is this method, [beginFrame], which proceeds as follows: + /// + /// 2. The build phase: All the dirty [Element]s in the widget tree are + /// rebuilt (see [State.build]). See [State.setState] for further details on + /// marking a widget dirty for building. See [BuildOwner] for more information + /// on this step. + /// + /// 3. The layout phase: All the dirty [RenderObject]s in the system are laid + /// out (see [RenderObject.performLayout]). See [RenderObject.markNeedsLayout] + /// for further details on marking an object dirty for layout. + /// + /// 4. The compositing bits phase: The compositing bits on any dirty + /// [RenderObject] objects are updated. See + /// [RenderObject.markNeedsCompositingBitsUpdate]. + /// + /// 5. The paint phase: All the dirty [RenderObject]s in the system are + /// repainted (see [RenderObject.paint]). This generates the [Layer] tree. See + /// [RenderObject.markNeedsPaint] for further details on marking an object + /// dirty for paint. + /// + /// 6. The compositing phase: The layer tree is turned into a [ui.Scene] and + /// sent to the GPU. + /// + /// 7. The semantics phase: All the dirty [RenderObject]s in the system have + /// their semantics updated (see [RenderObject.semanticAnnotator]). This + /// generates the [SemanticsNode] tree. See + /// [RenderObject.markNeedsSemanticsUpdate] for further details on marking an + /// object dirty for semantics. + /// + /// For more details on steps 3-7, see [PipelineOwner]. + /// + /// 8. The finalization phase in the widgets layer: The widgets tree is + /// finalized. This causes [State.dispose] to be invoked on any objects that + /// were removed from the widgets tree this frame. See + /// [BuildOwner.finalizeTree] for more details. + /// + /// 9. The finalization phase in the scheduler layer: After [beginFrame] + /// returns, [handleBeginFrame] then invokes post-frame callbacks (registered + /// with [addPostFrameCallback]. + // + // When editing the above, also update rendering/binding.dart's copy. @override void beginFrame() { assert(!_buildingDirtyElements); @@ -210,6 +267,7 @@ abstract class WidgetsBinding extends BindingBase implements GestureBinding, Ren super.beginFrame(); buildOwner.finalizeTree(); // TODO(ianh): Following code should not be included in release mode, only profile and debug modes. + // See https://github.com/dart-lang/sdk/issues/27192 if (_needToReportFirstFrame) { if (_thisFrameWasUseful) { developer.Timeline.instantSync('Widgets completed first useful frame'); @@ -298,7 +356,8 @@ class RenderObjectToWidgetAdapter extends RenderObjectWi @override void updateRenderObject(BuildContext context, RenderObject renderObject) { } - /// Inflate this widget and actually set the resulting [RenderObject] as the child of [container]. + /// Inflate this widget and actually set the resulting [RenderObject] as the + /// child of [container]. /// /// If `element` is null, this function will create a new element. Otherwise, /// the given element will be updated with this widget. diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart index 0e812856ab2..e3a7487a518 100644 --- a/packages/flutter/lib/src/widgets/debug.dart +++ b/packages/flutter/lib/src/widgets/debug.dart @@ -11,6 +11,12 @@ import 'table.dart'; /// Log the dirty widgets that are built each frame. bool debugPrintRebuildDirtyWidgets = false; +/// Log when widgets with global keys are deactivated and log when they are +/// reactivated (retaken). +/// +/// This can help track down framework bugs relating to the [GlobalKey] logic. +bool debugPrintGlobalKeyedWidgetLifecycle = false; + Key _firstNonUniqueKey(Iterable widgets) { Set keySet = new HashSet(); for (Widget widget in widgets) { diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 653b169b57e..e0fd09a07b3 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -968,6 +968,16 @@ abstract class _ProxyWidget extends Widget { final Widget child; } +/// Base class for widgets that hook [ParentData] information to children of +/// [RenderObjectWidget]s. +/// +/// This can be used to provide per-child configuration for +/// [RenderObjectWidget]s with more than one child. For example, [Stack] uses +/// the [Positioned] parent data widget to position each child. +/// +/// A [ParentDataWidget] is specific to a particular kind of [RenderObject], and +/// thus also to a particular [RenderObjectWidget] class. That class is `T`, the +/// [ParentDataWidget] type argument. abstract class ParentDataWidget extends _ProxyWidget { const ParentDataWidget({ Key key, Widget child }) : super(key: key, child: child); @@ -989,7 +999,7 @@ abstract class ParentDataWidget extends _ProxyWidg /// Subclasses should override this to describe the requirements for using the /// ParentDataWidget subclass. It is called when debugIsValidAncestor() /// returned false for an ancestor, or when there are extraneous - /// ParentDataWidgets in the ancestor chain. + /// [ParentDataWidget]s in the ancestor chain. String debugDescribeInvalidAncestorChain({ String description, String ownershipChain, bool foundValidAncestor, Iterable badAncestors }) { assert(T != dynamic); assert(T != RenderObjectWidget); @@ -1018,6 +1028,13 @@ abstract class ParentDataWidget extends _ProxyWidg void applyParentData(RenderObject renderObject); } +/// Base class for widgets that efficiently propagate information down the tree. +/// +/// To obtain the nearest instance of a particular type of inherited widget from +/// a build context, use [BuildContext.inheritFromWidgetOfExactType]. +/// +/// Inherited widgets, when referenced in this way, will cause the consumer to +/// rebuild when the inherited widget itself changes state. abstract class InheritedWidget extends _ProxyWidget { const InheritedWidget({ Key key, Widget child }) : super(key: key, child: child); @@ -1088,6 +1105,11 @@ abstract class MultiChildRenderObjectWidget extends RenderObjectWidget { assert(!children.any((Widget child) => child == null)); } + /// The widgets below this widget in the tree. + /// + /// If this list is going to be mutated, it is usually wise to put [Key]s on + /// the widgets, so that the framework can match old configurations to new + /// configurations and maintain the underlying render objects. final List children; @override @@ -1163,21 +1185,226 @@ class _InactiveElements { /// this callback. typedef void ElementVisitor(Element element); +/// A handle to the location of a widget in the widget tree. +/// +/// This class presents a set of methods that can be used from +/// [StatelessWidget.build] methods and from methods on [State] objects. +/// +/// [BuildContext] objects are passed to [WidgetBuilder] functions (such as +/// [StatelessWidget.build]), and are available from the [State.context] member. +/// Some static functions (e.g. [showDialog], [Theme.of], and so forth) also +/// take build contexts so that they can act on behalf of the calling widget, or +/// obtain data specifically for the given context. +/// +/// Each widget has its own [BuildContext], which becomes the parent of the +/// widget returned by the [StatelessWidget.build] or [State.build] function. +/// (And similarly, the parent of any children for [RenderObjectWidget]s.) +/// +/// In particular, this means that within a build method, the build context of +/// the widget of the build method is not the same as the build context of the +/// widgets returned by that build method. This can lead to some tricky cases. +/// For example, [Theme.of(context)] looks for the nearest enclosing [Theme] of +/// the given build context. If a build method for a widget Q includes a [Theme] +/// within its returned widget tree, and attempts to use [Theme.of] passing its +/// own context, the build method for Q will not find that [Theme] object. It +/// will instead find whatever [Theme] was an ancestor to the widget Q. If the +/// build context for a subpart of the returned tree is needed, a [Builder] +/// widget can be used: the build context passed to the [Builder.builder] +/// callback will be that of the [Builder] itself. +/// +/// For example, in the following snippet, the [ScaffoldState.showSnackBar] +/// method is called on the [Scaffold] widget that the build method itself +/// creates. If a [Builder] had not been used, and instead the `context` +/// argument of the build method itself had been used, no [Scaffold] would have +/// been found, and the [Scaffold.of] function would have returned null. +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// // here, Scaffold.of(context) returns null +/// return new Scaffold( +/// appBar: new AppBar(title: new Text('Demo')), +/// body: new Builder( +/// builder: (BuildContext context) { +/// return new FlatButton( +/// child: new Text('BUTTON'), +/// onPressed: () { +/// // here, Scaffold.of(context) returns the locally created Scaffold +/// Scaffold.of(context).showSnackBar(new SnackBar( +/// content: new Text('Hello.') +/// )); +/// } +/// ); +/// } +/// ) +/// ); +/// } +/// ``` +/// +/// The [BuildContext] for a particular widget can change location over time as +/// the widget is moved around the tree. Because of this, values returned from +/// the methods on this class should not be cached beyond the execution of a +/// single synchronous function. +/// +/// [BuildContext] objects are actually [Element] objects. The [BuildContext] +/// interface is used to discourage direct manipulation of [Element] objects. abstract class BuildContext { + /// The current configuration of the [Element] that is this [BuildContext]. Widget get widget; + + /// The current [RenderObject] for the widget. If the widget is a + /// [RenderObjectWidget], this is the render object that the widget created + /// for itself. Otherwise, it is the render object of the first descendant + /// [RenderObjectWidget]. + /// + /// This method will only return a valid result after the build phase is + /// complete. It is therefore not valid to call this from the [build] function + /// itself. It should only be called from interaction event handlers (e.g. + /// gesture callbacks) or layout or paint callbacks. + /// + /// If the render object is a [RenderBox], which is the common case, then the + /// size of the render object can be obtained from the [RenderBox.size] + /// getter. This is only valid after the layout phase, and should therefore + /// only be examined from paint callbacks or interaction event handlers (e.g. + /// gesture callbacks). + /// + /// For details on the different phases of a frame, see + /// [WidgetsBinding.beginFrame]. + /// + /// Calling this method is theoretically relatively expensive (O(N) in the + /// depth of the tree), but in practice is usually cheap because the tree + /// usually has many render objects and therefore the distance to the nearest + /// render object is usually short. RenderObject findRenderObject(); + + /// Obtains the nearest widget of the given type, which must be the type of a + /// concrete [InheritedWidget] subclass, and registers this build context with + /// that widget such that when that widget changes (or a new widget of that + /// type is introduced, or the widget goes away), this build context is + /// rebuilt so that it can obtain new values from that widget. + /// + /// This is typically called implicitly from `of()` static methods, e.g. + /// [Theme.of]. + /// + /// This should not be called from widget constructors or from + /// [State.initState] methods, because those methods would not get called + /// again if the inherited value were to change. To ensure that the widget + /// correctly updates itself when the inherited value changes, only call this + /// (directly or indirectly) from build methods or layout and paint callbacks. + /// + /// It is also possible to call this from interaction event handlers (e.g. + /// gesture callbacks) or timers, to obtain a value once, if that value is not + /// going to be cached and reused later. + /// + /// Calling this method is O(1) with a small constant factor, but will lead to + /// the widget being rebuilt more often. InheritedWidget inheritFromWidgetOfExactType(Type targetType); + + /// Returns the nearest ancestor widget of the given type, which must be the + /// type of a concrete [Widget] subclass. + /// + /// This should not be used from build methods, because the build context will + /// not be rebuilt if the value that would be returned by this method changes. + /// In general, [inheritFromWidgetOfExactType] is more useful. This method is + /// appropriate when used in interaction event handlers (e.g. gesture + /// callbacks), or for performing one-off tasks. + /// + /// Calling this method is relatively expensive (O(N) in the depth of the + /// tree). Only call this method if the distance from this widget to the + /// desired ancestor is known to be small and bounded. Widget ancestorWidgetOfExactType(Type targetType); + + /// Returns the [State] object of the nearest ancestor [StatefulWidget] widget + /// that matches the given [TypeMatcher]. + /// + /// This should not be used from build methods, because the build context will + /// not be rebuilt if the value that would be returned by this method changes. + /// In general, [inheritFromWidgetOfExactType] is more appropriate for such + /// cases. This method is useful to change the state of an ancestor widget in + /// a one-off manner, for example, to cause an ancestor scrolling list to + /// scroll this build context's widget into view, or to move the focus in + /// response to user interaction. + /// + /// In general, though, consider using a callback that triggers a stateful + /// change in the ancestor rather than using the imperative style implied by + /// this method. This will usually lead to more maintainable and reusable code + /// since it decouples widgets from each other. + /// + /// Calling this method is relatively expensive (O(N) in the depth of the + /// tree). Only call this method if the distance from this widget to the + /// desired ancestor is known to be small and bounded. State ancestorStateOfType(TypeMatcher matcher); + + /// Returns the [RenderObject] object of the nearest ancestor [RenderObjectWidget] widget + /// that matches the given [TypeMatcher]. + /// + /// This should not be used from build methods, because the build context will + /// not be rebuilt if the value that would be returned by this method changes. + /// In general, [inheritFromWidgetOfExactType] is more appropriate for such + /// cases. This method is useful only in esoteric cases where a widget needs + /// to cause an ancestor to change its layout or paint behavior. For example, + /// it is used by [Material] so that [InkWell] widgets can trigger the ink + /// splash on the [Material]'s actual render object. + /// + /// Calling this method is relatively expensive (O(N) in the depth of the + /// tree). Only call this method if the distance from this widget to the + /// desired ancestor is known to be small and bounded. RenderObject ancestorRenderObjectOfType(TypeMatcher matcher); + + /// Walks the ancestor chain, starting with the parent of this build context's + /// widget, invoking the argument for each ancestor. The callback is given a + /// reference to the ancestor widget's corresponding [Element] object. The + /// walk stops when it reaches the root widget or when the callback returns + /// false. The callback must not return null. + /// + /// This is useful for inspecting the widget tree. + /// + /// Calling this method is relatively expensive (O(N) in the depth of the tree). void visitAncestorElements(bool visitor(Element element)); + + /// Walks the children of this widget. + /// + /// This is useful for applying changes to children after they are built + /// without waiting for the next frame, especially if the children are known, + /// and especially if there is exactly one child (as is always the case for + /// [StatefulWidget]s or [StatelessWidget]s). + /// + /// Calling this method is very cheap for build contexts that correspond to + /// [StatefulWidget]s or [StatelessWidget]s (O(1), since there's only one + /// child). + /// + /// Calling this method is potentially expensive for build contexts that + /// correspond to [RenderObjectWidget]s (O(N) in the number of children). + /// + /// Calling this method recursively is extremely expensive (O(N) in the number + /// of descendants), and should be avoided if possible. Generally it is + /// significantly cheaper to use an [InheritedWidget] and have the descendants + /// pull data down, than it is to use [visitChildElements] recursively to push + /// data down to them. void visitChildElements(ElementVisitor visitor); } +/// Manager class for the widgets framework. +/// +/// This class tracks which widgets need rebuilding, and handles other tasks +/// that apply to widget trees as a whole, such as managing the inactive element +/// list for the tree and triggering the "reassemble" command when necessary +/// during debugging. +/// +/// The main build owner is typically owned by the [WidgetsBinding], and is +/// driven from the operating system along with the rest of the +/// build/layout/paint pipeline. +/// +/// Additional build owners can be built to manage off-screen widget trees. +/// +/// To assign a build owner to a tree, use the +/// [RootRenderObjectElement.assignOwner] method on the root element of the +/// widget tree. class BuildOwner { BuildOwner({ this.onBuildScheduled }); - /// Called on each build pass when the first buildable element is marked dirty + /// Called on each build pass when the first buildable element is marked + /// dirty. VoidCallback onBuildScheduled; final _InactiveElements _inactiveElements = new _InactiveElements(); @@ -1185,7 +1412,7 @@ class BuildOwner { final List _dirtyElements = []; /// Adds an element to the dirty elements list so that it will be rebuilt - /// when buildDirtyElements is called. + /// when [buildDirtyElements] is called. void scheduleBuildFor(BuildableElement element) { assert(() { if (_dirtyElements.contains(element)) { @@ -1271,10 +1498,11 @@ class BuildOwner { return 0; } - /// Builds all the elements that were marked as dirty using schedule(), in depth order. - /// If elements are marked as dirty while this runs, they must be deeper than the algorithm - /// has yet reached. - /// This is called by beginFrame(). + /// Builds all the elements that were marked as dirty using + /// [scheduleBuildFor], in depth order. If elements are marked as dirty while + /// this runs, they must be deeper than the algorithm has yet reached. + /// + /// This is called by [WidgetsBinding.beginFrame]. void buildDirtyElements() { if (_dirtyElements.isEmpty) return; @@ -1303,7 +1531,7 @@ class BuildOwner { /// Complete the element build pass by unmounting any elements that are no /// longer active. /// - /// This is called by beginFrame(). + /// This is called by [WidgetsBinding.beginFrame]. /// /// In checked mode, this also verifies that each global key is used at most /// once. @@ -1325,13 +1553,11 @@ class BuildOwner { } } - /// Cause the entire subtree rooted at the given [Element] to - /// be entirely rebuilt. This is used by development tools when - /// the application code has changed, to cause the widget tree to - /// pick up any changed implementations. + /// Cause the entire subtree rooted at the given [Element] to be entirely + /// rebuilt. This is used by development tools when the application code has + /// changed, to cause the widget tree to pick up any changed implementations. /// - /// This is expensive and should not be called except during - /// development. + /// This is expensive and should not be called except during development. void reassemble(Element root) { assert(root._parent == null); assert(root.owner == this); @@ -1394,12 +1620,19 @@ abstract class Element implements BuildContext { return result; } - /// This is used to verify that Element objects move through life in an orderly fashion. + // This is used to verify that Element objects move through life in an + // orderly fashion. _ElementLifecycle _debugLifecycleState = _ElementLifecycle.initial; - /// Calls the argument for each child. Must be overridden by subclasses that support having children. + /// Calls the argument for each child. Must be overridden by subclasses that + /// support having children. void visitChildren(ElementVisitor visitor) { } + /// Calls the argument for each child that is relevant for semantics. By + /// default, defers to [visitChildren]. Classes like [Offstage] override this + /// to hide their children. + void visitChildrenForSemantics(ElementVisitor visitor) => visitChildren(visitor); + /// Wrapper around visitChildren for BuildContext. @override void visitChildElements(void visitor(Element element)) { @@ -1554,6 +1787,11 @@ abstract class Element implements BuildContext { return null; if (!Widget.canUpdate(element.widget, newWidget)) return null; + assert(() { + if (debugPrintGlobalKeyedWidgetLifecycle) + debugPrint('Attempting to take $element from ${element._parent ?? "inactive elements list"} to put in $this'); + return true; + }); if (element._parent != null && !element._parent.detachChild(element)) return null; assert(element._parent == null); @@ -1601,6 +1839,13 @@ abstract class Element implements BuildContext { child._parent = null; child.detachRenderObject(); owner._inactiveElements.add(child); // this eventually calls child.deactivate() + assert(() { + if (debugPrintGlobalKeyedWidgetLifecycle) { + if (child.widget.key is GlobalKey) + debugPrint('Deactivated $child (keyed child of $this)'); + } + return true; + }); } void _activateWithParent(Element parent, dynamic newSlot) { @@ -1623,6 +1868,11 @@ abstract class Element implements BuildContext { /// instead of being unmounted (see [unmount]). @mustCallSuper void activate() { + assert(() { + if (debugPrintGlobalKeyedWidgetLifecycle) + debugPrint('Reactivating $this (child of $_parent).'); + return true; + }); assert(_debugLifecycleState == _ElementLifecycle.inactive); assert(widget != null); assert(depth != null); @@ -2027,7 +2277,7 @@ abstract class ComponentElement extends BuildableElement { built = _builder(this); debugWidgetBuilderValue(widget, built); } catch (e, stack) { - _debugReportException('building $_widget', e, stack); + _debugReportException('building $this', e, stack); built = new ErrorWidget(e); } finally { // We delay marking the element as clean until after calling _builder so @@ -2039,7 +2289,7 @@ abstract class ComponentElement extends BuildableElement { _child = updateChild(_child, built, slot); assert(_child != null); } catch (e, stack) { - _debugReportException('building $_widget', e, stack); + _debugReportException('building $this', e, stack); built = new ErrorWidget(e); _child = updateChild(null, built, slot); } @@ -2247,6 +2497,7 @@ abstract class _ProxyElement extends ComponentElement { void notifyClients(_ProxyWidget oldWidget); } +/// Base class for instantiations of [ParentDataWidget] subclasses. class ParentDataElement extends _ProxyElement { ParentDataElement(ParentDataWidget widget) : super(widget); @@ -2297,7 +2548,7 @@ class ParentDataElement extends _ProxyElement { } } - +/// Base class for instantiations of [InheritedWidget] subclasses. class InheritedElement extends _ProxyElement { InheritedElement(InheritedWidget widget) : super(widget); @@ -2356,14 +2607,14 @@ class InheritedElement extends _ProxyElement { } } -/// Base class for instantiations of RenderObjectWidget subclasses +/// Base class for instantiations of [RenderObjectWidget] subclasses. abstract class RenderObjectElement extends BuildableElement { RenderObjectElement(RenderObjectWidget widget) : super(widget); @override RenderObjectWidget get widget => super.widget; - /// The underlying [RenderObject] for this element + /// The underlying [RenderObject] for this element. @override RenderObject get renderObject => _renderObject; RenderObject _renderObject; @@ -2638,27 +2889,39 @@ abstract class RenderObjectElement extends BuildableElement { } } -/// Instantiation of RenderObjectWidgets at the root of the tree +/// Instantiation of RenderObjectWidgets at the root of the tree. /// /// Only root elements may have their owner set explicitly. All other /// elements inherit their owner from their parent. abstract class RootRenderObjectElement extends RenderObjectElement { RootRenderObjectElement(RenderObjectWidget widget): super(widget); + /// Set the owner of the element. The owner will be propagated to all the + /// descendants of this element. + /// + /// The owner manages the dirty elements list. + /// + /// The [WidgetsBinding] introduces the primary owner, + /// [WidgetsBinding.buildOwner], and assigns it to the widget tree in the call + /// to [runApp]. The binding is responsible for driving the build pipeline by + /// calling the build owner's [BuildOwner.buildDirtyElements] method. See + /// [WidgetsBinding.beginFrame]. void assignOwner(BuildOwner owner) { _owner = owner; } @override void mount(Element parent, dynamic newSlot) { - // Root elements should never have parents + // Root elements should never have parents. assert(parent == null); assert(newSlot == null); super.mount(parent, newSlot); } } -/// Instantiation of RenderObjectWidgets that have no children +/// Instantiation of [RenderObjectWidget]s that have no children. +/// +/// Such widgets are expected to inherit from [LeafRenderObjectWidget]. class LeafRenderObjectElement extends RenderObjectElement { LeafRenderObjectElement(LeafRenderObjectWidget widget): super(widget); @@ -2678,7 +2941,13 @@ class LeafRenderObjectElement extends RenderObjectElement { } } -/// Instantiation of RenderObjectWidgets that have up to one child +/// Instantiation of [RenderObjectWidget]s that have up to one child. +/// +/// The child is optional. +/// +/// This element subclass can be used for RenderObjectWidgets whose +/// RenderObjects use the [RenderObjectWithChildMixin] mixin. Such widgets are +/// expected to inherit from [SingleChildRenderObjectWidget]. class SingleChildRenderObjectElement extends RenderObjectElement { SingleChildRenderObjectElement(SingleChildRenderObjectWidget widget) : super(widget); @@ -2736,7 +3005,12 @@ class SingleChildRenderObjectElement extends RenderObjectElement { } } -/// Instantiation of RenderObjectWidgets that can have a list of children +/// Instantiation of [RenderObjectWidget]s that can have a list of children. +/// +/// This element subclass can be used for RenderObjectWidgets whose +/// RenderObjects use the [ContainerRenderObjectMixin] mixin with a parent data +/// type that implements [ContainerParentDataMixin]. Such widgets +/// are expected to inherit from [MultiChildRenderObjectWidget]. class MultiChildRenderObjectElement extends RenderObjectElement { MultiChildRenderObjectElement(MultiChildRenderObjectWidget widget) : super(widget) { assert(!debugChildrenHaveDuplicateKeys(widget, widget.children)); @@ -2793,8 +3067,8 @@ class MultiChildRenderObjectElement extends RenderObjectElement { super.mount(parent, newSlot); _children = new List(widget.children.length); Element previousChild; - for (int i = 0; i < _children.length; ++i) { - Element newChild = inflateWidget(widget.children[i], previousChild); + for (int i = 0; i < _children.length; i += 1) { + final Element newChild = inflateWidget(widget.children[i], previousChild); _children[i] = newChild; previousChild = newChild; } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index a88b67af562..b4f3ad587c1 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -103,8 +103,8 @@ abstract class Route { /// route), then [isCurrent] will also be true. /// /// If a later route is entirely opaque, then the route will be active but not - /// rendered. In particular, it's possible for a route to be active but for - /// stateful widgets within the route to not be instantiated. + /// rendered. It is even possible for the route to be active but for the stateful + /// widgets within the route to not be instatiated. See [ModalRoute.maintainState]. bool get isActive { if (_navigator == null) return false; @@ -118,7 +118,7 @@ class RouteSettings { /// Creates data used to construct routes. const RouteSettings({ this.name, - this.isInitialRoute: false + this.isInitialRoute: false, }); /// The name of the route (e.g., "/settings"). diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index c8784050ed9..3877efb1d32 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -2,9 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:collection'; + import 'package:meta/meta.dart'; +import 'package:flutter/rendering.dart'; import 'basic.dart'; +import 'debug.dart'; import 'framework.dart'; /// A place in an [Overlay] that can contain a widget. @@ -31,6 +35,14 @@ import 'framework.dart'; /// [Draggable] removes the entry from the overlay to remove the drag avatar /// from view. /// +/// By default, if there is an entirely [opaque] entry over this one, then this +/// one will not be included in the widget tree (in particular, stateful widgets +/// within the overlay entry will not be instantiated). To ensure that your +/// overlay entry is still built even if it is not visible, set [maintainState] +/// to true. This is more expensive, so should be done with care. In particular, +/// if widgets in an overlay entry with [maintainState] set to true repeatedly +/// call [setState], the user's battery will be drained unnecessarily. +/// /// See also: /// /// * [Overlay] @@ -45,12 +57,16 @@ class OverlayEntry { /// call [remove] on the overlay entry itself. OverlayEntry({ @required this.builder, - bool opaque: false - }) : _opaque = opaque { + bool opaque: false, + bool maintainState: false, + }) : _opaque = opaque, _maintainState = maintainState { assert(builder != null); + assert(opaque != null); + assert(maintainState != null); } - /// This entry will include the widget built by this builder in the overlay at the entry's position. + /// This entry will include the widget built by this builder in the overlay at + /// the entry's position. /// /// To cause this builder to be called again, call [markNeedsBuild] on this /// overlay entry. @@ -58,8 +74,9 @@ class OverlayEntry { /// Whether this entry occludes the entire overlay. /// - /// If an entry claims to be opaque, the overlay will skip building all the - /// entries below that entry for efficiency. + /// If an entry claims to be opaque, then, for efficiency, the overlay will + /// skip building entries below that entry unless they have [maintainState] + /// set. bool get opaque => _opaque; bool _opaque; set opaque (bool value) { @@ -70,6 +87,31 @@ class OverlayEntry { _overlay._didChangeEntryOpacity(); } + /// Whether this entry must be included in the tree even if there is a fully + /// [opaque] entry above it. + /// + /// By default, if there is an entirely [opaque] entry over this one, then this + /// one will not be included in the widget tree (in particular, stateful widgets + /// within the overlay entry will not be instantiated). To ensure that your + /// overlay entry is still built even if it is not visible, set [maintainState] + /// to true. This is more expensive, so should be done with care. In particular, + /// if widgets in an overlay entry with [maintainState] set to true repeatedly + /// call [setState], the user's battery will be drained unnecessarily. + /// + /// This is used by the [Navigator] and [Route] objects to ensure that routes + /// are kept around even when in the background, so that [Future]s promised + /// from subsequent routes will be handled properly when they complete. + bool get maintainState => _maintainState; + bool _maintainState; + set maintainState (bool value) { + assert(_maintainState != null); + if (_maintainState == value) + return; + _maintainState = value; + assert(_overlay != null); + _overlay._didChangeEntryOpacity(); + } + OverlayState _overlay; final GlobalKey<_OverlayEntryState> _key = new GlobalKey<_OverlayEntryState>(); @@ -87,11 +129,13 @@ class OverlayEntry { } @override - String toString() => '$runtimeType@$hashCode(opaque: $opaque)'; + String toString() => '$runtimeType@$hashCode(opaque: $opaque; maintainState: $maintainState)'; } class _OverlayEntry extends StatefulWidget { - _OverlayEntry(OverlayEntry entry) : entry = entry, super(key: entry._key); + _OverlayEntry(OverlayEntry entry) : entry = entry, super(key: entry._key) { + assert(entry != null); + } final OverlayEntry entry; @@ -101,7 +145,9 @@ class _OverlayEntry extends StatefulWidget { class _OverlayEntryState extends State<_OverlayEntry> { @override - Widget build(BuildContext context) => config.entry.builder(context); + Widget build(BuildContext context) { + return config.entry.builder(context); + } void _markNeedsBuild() { setState(() { /* the state that changed is in the builder */ }); @@ -227,7 +273,8 @@ class OverlayState extends State { setState(() { /* entry was removed */ }); } - /// (DEBUG ONLY) Check whether a given entry is visible (i.e., not behind an opaque entry). + /// (DEBUG ONLY) Check whether a given entry is visible (i.e., not behind an + /// opaque entry). /// /// This is an O(N) algorithm, and should not be necessary except for debug /// asserts. To avoid people depending on it, this function is implemented @@ -259,16 +306,26 @@ class OverlayState extends State { @override Widget build(BuildContext context) { - List backwardsChildren = []; - - for (int i = _entries.length - 1; i >= 0; --i) { + // 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 = []; + bool onstage = true; + for (int i = _entries.length - 1; i >= 0; i -= 1) { OverlayEntry entry = _entries[i]; - backwardsChildren.add(new _OverlayEntry(entry)); - if (entry.opaque) - break; + if (onstage) { + onstageChildren.add(new _OverlayEntry(entry)); + if (entry.opaque) + onstage = false; + } else if (entry.maintainState) { + offstageChildren.add(new _OverlayEntry(entry)); + } } - - return new Stack(children: backwardsChildren.reversed.toList(growable: false)); + return new _Theatre( + onstage: new Stack(children: onstageChildren.reversed.toList(growable: false)), + offstage: offstageChildren, + ); } @override @@ -277,3 +334,214 @@ class OverlayState extends State { description.add('entries: $_entries'); } } + +/// 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. +/// +/// The onstage widget must be a Stack. +/// +/// For convenience, it is legal to use [Positioned] widgets around the offstage +/// widgets. +class _Theatre extends RenderObjectWidget { + _Theatre({ + this.onstage, + this.offstage, + }) { + assert(offstage != null); + assert(!offstage.any((Widget child) => child == null)); + } + + final Stack onstage; + + final List offstage; + + @override + _TheatreElement createElement() => new _TheatreElement(this); + + @override + _RenderTheatre createRenderObject(BuildContext context) => new _RenderTheatre(); +} + +class _TheatreElement extends RenderObjectElement { + _TheatreElement(_Theatre widget) : super(widget) { + assert(!debugChildrenHaveDuplicateKeys(widget, widget.offstage)); + } + + @override + _Theatre get widget => super.widget; + + @override + _RenderTheatre get renderObject => super.renderObject; + + Element _onstage; + static final Object _onstageSlot = new Object(); + + List _offstage; + final Set _detachedOffstageChildren = new HashSet(); + + @override + void insertChildRenderObject(RenderBox child, dynamic slot) { + if (slot == _onstageSlot) { + assert(child is RenderStack); + renderObject.child = child; + } else { + assert(slot == null || slot is Element); + renderObject.insert(child, after: slot?.renderObject); + } + } + + @override + void moveChildRenderObject(RenderBox child, dynamic slot) { + if (slot == _onstageSlot) { + renderObject.remove(child); + assert(child is RenderStack); + renderObject.child = child; + } else { + assert(slot == null || slot is Element); + if (renderObject.child == child) { + renderObject.child = null; + renderObject.insert(child, after: slot?.renderObject); + } else { + renderObject.move(child, after: slot?.renderObject); + } + } + } + + @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 (Element child in _offstage) { + if (!_detachedOffstageChildren.contains(child)) + visitor(child); + } + } + + @override + void visitChildrenForSemantics(ElementVisitor visitor) { + if (_onstage != null) + visitor(_onstage); + } + + @override + bool detachChild(Element child) { + if (child == _onstage) { + _onstage = null; + } else { + _detachedOffstageChildren.add(child); + } + deactivateChild(child); + return true; + } + + @override + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + _onstage = updateChild(_onstage, widget.onstage, _onstageSlot); + _offstage = new 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, detachedChildren: _detachedOffstageChildren); + _detachedOffstageChildren.clear(); + } +} + +// 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 { + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! StackParentData) + child.parentData = new 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. + + @override + void redepthChildren() { + if (child != null) + redepthChild(child); + super.redepthChildren(); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (child != null) + visitor(child); + super.visitChildren(visitor); + } + + @override + String debugDescribeChildren(String prefix) { + String result = ''; + if (child != null) + result += '$prefix \u2502\n${child.toStringDeep('$prefix \u251C\u2500onstage: ', '$prefix \u254E')}'; + if (firstChild != null) { + RenderBox child = firstChild; + int count = 1; + while (child != lastChild) { + result += '${child.toStringDeep("$prefix \u254E\u254Coffstage $count: ", "$prefix \u254E")}'; + count += 1; + final StackParentData childParentData = child.parentData; + child = childParentData.nextSibling; + } + if (child != null) { + assert(child == lastChild); + result += '${child.toStringDeep("$prefix \u2514\u254Coffstage $count: ", "$prefix ")}'; + } + } else { + result += '$prefix \u2514\u254Cno offstage children'; + } + return result; + } + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + if (child != null) + visitor(child); + } +} \ No newline at end of file diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index babbca051da..fdb5c33c774 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -20,7 +20,7 @@ const Color _kTransparent = const Color(0x00000000); /// A route that displays widgets in the [Navigator]'s [Overlay]. abstract class OverlayRoute extends Route { /// Subclasses should override this getter to return the builders for the overlay. - List get builders; + Iterable createOverlayEntries(); /// The entries this route has placed in the overlay. @override @@ -30,8 +30,7 @@ abstract class OverlayRoute extends Route { @override void install(OverlayEntry insertionPoint) { assert(_overlayEntries.isEmpty); - for (WidgetBuilder builder in builders) - _overlayEntries.add(new OverlayEntry(builder: builder)); + _overlayEntries.addAll(createOverlayEntries()); navigator.overlay?.insertAll(_overlayEntries, above: insertionPoint); } @@ -415,7 +414,7 @@ class _ModalScopeState extends State<_ModalScope> { Widget build(BuildContext context) { return new Focus( key: config.route.focusKey, - child: new OffStage( + child: new Offstage( offstage: config.route.offstage, child: new IgnorePointer( ignoring: config.route.animation?.status == AnimationStatus.reverse, @@ -577,6 +576,13 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute get builders => [ - _buildModalBarrier, - _buildModalScope - ]; + Iterable createOverlayEntries() sync* { + yield new OverlayEntry(builder: _buildModalBarrier); + yield new OverlayEntry(builder: _buildModalScope, maintainState: maintainState); + } @override String toString() => '$runtimeType($settings, animation: $_animation)'; @@ -671,6 +677,9 @@ abstract class PopupRoute extends ModalRoute { @override bool get opaque => false; + @override + bool get maintainState => true; + @override void didChangeNext(Route nextRoute) { assert(nextRoute is! PageRoute); diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index 93aada3e796..844b84b9464 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; void main() { - testWidgets('Navigator.push works within a PopupMenuButton ', (WidgetTester tester) async { + testWidgets('Navigator.push works within a PopupMenuButton', (WidgetTester tester) async { await tester.pumpWidget( new MaterialApp( routes: { @@ -46,9 +46,9 @@ void main() { expect(find.text('Next'), findsNothing); await tester.tap(find.text('One')); - - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); // finish the menu animation + await tester.pump(); // return the future + await tester.pump(); // start the navigation + await tester.pump(const Duration(seconds: 1)); // end the navigation expect(find.text('One'), findsNothing); expect(find.text('Next'), findsOneWidget); diff --git a/packages/flutter/test/rendering/offstage_test.dart b/packages/flutter/test/rendering/offstage_test.dart index af8e7c1ac21..87e7c6fac6e 100644 --- a/packages/flutter/test/rendering/offstage_test.dart +++ b/packages/flutter/test/rendering/offstage_test.dart @@ -15,7 +15,7 @@ void main() { // viewport incoming constraints are tight 800x600 // viewport is vertical by default RenderBox root = new RenderViewport( - child: new RenderOffStage( + child: new RenderOffstage( child: new RenderCustomPaint( painter: new TestCallbackPainter( onPaint: () { painted = true; } diff --git a/packages/flutter/test/widget/heroes_test.dart b/packages/flutter/test/widget/heroes_test.dart index 7cea2e0bc87..819ff8887f1 100644 --- a/packages/flutter/test/widget/heroes_test.dart +++ b/packages/flutter/test/widget/heroes_test.dart @@ -59,20 +59,20 @@ void main() { // the initial setup. - expect(find.byKey(firstKey), isOnStage); + expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('two')); await tester.pump(); // begin navigation - // at this stage, the second route is off-stage, so that we can form the + // at this stage, the second route is offstage, so that we can form the // hero party. - expect(find.byKey(firstKey), isOnStage); + expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); - expect(find.byKey(secondKey), isOffStage); - expect(find.byKey(secondKey), isInCard); + expect(find.byKey(secondKey, skipOffstage: false), isOffstage); + expect(find.byKey(secondKey, skipOffstage: false), isInCard); await tester.pump(); @@ -80,7 +80,7 @@ void main() { // seeing them at t=16ms. The original page no longer contains the hero. expect(find.byKey(firstKey), findsNothing); - expect(find.byKey(secondKey), isOnStage); + expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isNotInCard); await tester.pump(); @@ -88,16 +88,17 @@ void main() { // t=32ms for the journey. Surely they are still at it. expect(find.byKey(firstKey), findsNothing); - expect(find.byKey(secondKey), isOnStage); + expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isNotInCard); await tester.pump(new Duration(seconds: 1)); // t=1.032s for the journey. The journey has ended (it ends this frame, in - // fact). The hero should now be in the new page, on-stage. + // fact). The hero should now be in the new page, onstage. The original + // widget will be back as well now (though not visible). expect(find.byKey(firstKey), findsNothing); - expect(find.byKey(secondKey), isOnStage); + expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); await tester.pump(); @@ -105,7 +106,7 @@ void main() { // Should not change anything. expect(find.byKey(firstKey), findsNothing); - expect(find.byKey(secondKey), isOnStage); + expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); // Now move on to view 3 @@ -113,13 +114,13 @@ void main() { await tester.tap(find.text('three')); await tester.pump(); // begin navigation - // at this stage, the second route is off-stage, so that we can form the + // at this stage, the second route is offstage, so that we can form the // hero party. - expect(find.byKey(secondKey), isOnStage); + expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); - expect(find.byKey(thirdKey), isOffStage); - expect(find.byKey(thirdKey), isInCard); + expect(find.byKey(thirdKey, skipOffstage: false), isOffstage); + expect(find.byKey(thirdKey, skipOffstage: false), isInCard); await tester.pump(); @@ -127,7 +128,7 @@ void main() { // seeing them at t=16ms. The original page no longer contains the hero. expect(find.byKey(secondKey), findsNothing); - expect(find.byKey(thirdKey), isOnStage); + expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isNotInCard); await tester.pump(); @@ -135,16 +136,16 @@ void main() { // t=32ms for the journey. Surely they are still at it. expect(find.byKey(secondKey), findsNothing); - expect(find.byKey(thirdKey), isOnStage); + expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isNotInCard); await tester.pump(new Duration(seconds: 1)); // t=1.032s for the journey. The journey has ended (it ends this frame, in - // fact). The hero should now be in the new page, on-stage. + // fact). The hero should now be in the new page, onstage. expect(find.byKey(secondKey), findsNothing); - expect(find.byKey(thirdKey), isOnStage); + expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isInCard); await tester.pump(); @@ -152,7 +153,7 @@ void main() { // Should not change anything. expect(find.byKey(secondKey), findsNothing); - expect(find.byKey(thirdKey), isOnStage); + expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isInCard); }); @@ -187,8 +188,8 @@ void main() { final Duration duration = const Duration(milliseconds: 300); final Curve curve = Curves.fastOutSlowIn; - final double initialHeight = tester.getSize(find.byKey(firstKey)).height; - final double finalHeight = tester.getSize(find.byKey(secondKey)).height; + final double initialHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height; + final double finalHeight = tester.getSize(find.byKey(secondKey, skipOffstage: false)).height; final double deltaHeight = finalHeight - initialHeight; final double epsilon = 0.001; @@ -267,18 +268,18 @@ void main() { navigator.pushNamed('/next'); expect(log, isEmpty); - await tester.tap(find.text('foo')); + await tester.tap(find.text('foo', skipOffstage: false)); expect(log, isEmpty); await tester.pump(new Duration(milliseconds: 10)); - await tester.tap(find.text('foo')); + await tester.tap(find.text('foo', skipOffstage: false)); expect(log, isEmpty); - await tester.tap(find.text('bar')); + await tester.tap(find.text('bar', skipOffstage: false)); expect(log, isEmpty); await tester.pump(new Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); - await tester.tap(find.text('bar')); + await tester.tap(find.text('bar', skipOffstage: false)); expect(log, isEmpty); await tester.pump(new Duration(seconds: 1)); diff --git a/packages/flutter/test/widget/navigator_test.dart b/packages/flutter/test/widget/navigator_test.dart index c53176107a0..28c5fce3991 100644 --- a/packages/flutter/test/widget/navigator_test.dart +++ b/packages/flutter/test/widget/navigator_test.dart @@ -69,33 +69,51 @@ class ThirdWidget extends StatelessWidget { void main() { testWidgets('Can navigator navigate to and from a stateful widget', (WidgetTester tester) async { final Map routes = { - '/': (BuildContext context) => new FirstWidget(), - '/second': (BuildContext context) => new SecondWidget(), + '/': (BuildContext context) => new FirstWidget(), // X + '/second': (BuildContext context) => new SecondWidget(), // Y }; await tester.pumpWidget(new MaterialApp(routes: routes)); - expect(find.text('X'), findsOneWidget); - expect(find.text('Y'), findsNothing); + expect(find.text('Y', skipOffstage: false), findsNothing); await tester.tap(find.text('X')); - await tester.pump(const Duration(milliseconds: 10)); + await tester.pump(); + expect(find.text('X'), findsOneWidget); + expect(find.text('Y', skipOffstage: false), isOffstage); + await tester.pump(const Duration(milliseconds: 10)); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(milliseconds: 10)); + expect(find.text('X'), findsOneWidget); + expect(find.text('Y'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text('X'), findsOneWidget); + expect(find.text('Y'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('X'), findsNothing); + expect(find.text('X', skipOffstage: false), findsOneWidget); + expect(find.text('Y'), findsOneWidget); await tester.tap(find.text('Y')); - await tester.pump(const Duration(milliseconds: 10)); - await tester.pump(const Duration(milliseconds: 10)); - await tester.pump(const Duration(milliseconds: 10)); - await tester.pump(const Duration(seconds: 1)); + expect(find.text('X'), findsNothing); + expect(find.text('Y'), findsOneWidget); + await tester.pump(); expect(find.text('X'), findsOneWidget); - expect(find.text('Y'), findsNothing); + expect(find.text('Y'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text('X'), findsOneWidget); + expect(find.text('Y'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + expect(find.text('X'), findsOneWidget); + expect(find.text('Y', skipOffstage: false), findsNothing); }); testWidgets('Navigator.of fails gracefully when not found in context', (WidgetTester tester) async { diff --git a/packages/flutter/test/widget/page_forward_transitions_test.dart b/packages/flutter/test/widget/page_forward_transitions_test.dart index 0570609efbd..e90030a41c2 100644 --- a/packages/flutter/test/widget/page_forward_transitions_test.dart +++ b/packages/flutter/test/widget/page_forward_transitions_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart' hide TypeMatcher; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; class TestTransition extends AnimatedWidget { TestTransition({ @@ -26,7 +27,7 @@ class TestTransition extends AnimatedWidget { } class TestRoute extends PageRoute { - TestRoute({ this.child, RouteSettings settings}) : super(settings: settings); + TestRoute({ this.child, RouteSettings settings }) : super(settings: settings); final Widget child; @@ -36,6 +37,9 @@ class TestRoute extends PageRoute { @override Color get barrierColor => null; + @override + bool get maintainState => false; + @override Widget buildPage(BuildContext context, Animation animation, Animation forwardAnimation) { return child; @@ -50,21 +54,21 @@ void main() { GlobalKey insideKey = new GlobalKey(); - String state() { + String state({ bool skipOffstage: true }) { String result = ''; - if (tester.any(find.text('A'))) + if (tester.any(find.text('A', skipOffstage: skipOffstage))) result += 'A'; - if (tester.any(find.text('B'))) + if (tester.any(find.text('B', skipOffstage: skipOffstage))) result += 'B'; - if (tester.any(find.text('C'))) + if (tester.any(find.text('C', skipOffstage: skipOffstage))) result += 'C'; - if (tester.any(find.text('D'))) + if (tester.any(find.text('D', skipOffstage: skipOffstage))) result += 'D'; - if (tester.any(find.text('E'))) + if (tester.any(find.text('E', skipOffstage: skipOffstage))) result += 'E'; - if (tester.any(find.text('F'))) + if (tester.any(find.text('F', skipOffstage: skipOffstage))) result += 'F'; - if (tester.any(find.text('G'))) + if (tester.any(find.text('G', skipOffstage: skipOffstage))) result += 'G'; return result; } @@ -112,7 +116,8 @@ void main() { navigator.pushNamed('/2'); expect(state(), equals('BC')); // transition 1->2 is not yet built await tester.pump(); - expect(state(), equals('BCE')); // transition 1->2 is at 0.0 + expect(state(), equals('BC')); // transition 1->2 is at 0.0 + expect(state(skipOffstage: false), equals('BCE')); // E is offstage await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('BCE')); // transition 1->2 is at 0.4 @@ -122,7 +127,7 @@ void main() { await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('E')); // transition 1->2 is at 1.0 - + expect(state(skipOffstage: false), equals('E')); // B and C are gone, the route is inactive with maintainState=false navigator.pop(); expect(state(), equals('E')); // transition 1<-2 is at 1.0, just reversed @@ -135,10 +140,12 @@ void main() { navigator.pushNamed('/3'); expect(state(), equals('BDE')); // transition 1<-2 is at 0.6 await tester.pump(); - expect(state(), equals('BDEF')); // transition 1<-2 is at 0.6, 1->3 is at 0.0 + expect(state(), equals('BDE')); // transition 1<-2 is at 0.6, 1->3 is at 0.0 + expect(state(skipOffstage: false), equals('BDEF')); // F is offstage since we're at 0.0 await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('BCEF')); // transition 1<-2 is at 0.2, 1->3 is at 0.4 + expect(state(skipOffstage: false), equals('BCEF')); // nothing secret going on here await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('BDF')); // transition 1<-2 is done, 1->3 is at 0.8 @@ -157,7 +164,8 @@ void main() { navigator.pushNamed('/4'); expect(state(), equals('BCF')); // transition 1<-3 is at 0.2, 1->4 is not yet built await tester.pump(); - expect(state(), equals('BCFG')); // transition 1<-3 is at 0.2, 1->4 is at 0.0 + expect(state(), equals('BCF')); // transition 1<-3 is at 0.2, 1->4 is at 0.0 + expect(state(skipOffstage: false), equals('BCFG')); // G is offstage await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('BCG')); // transition 1<-3 is done, 1->4 is at 0.4 @@ -167,6 +175,7 @@ void main() { await tester.pump(kFourTenthsOfTheTransitionDuration); expect(state(), equals('G')); // transition 1->4 is done + expect(state(skipOffstage: false), equals('G')); // route 1 is not around any more }); } diff --git a/packages/flutter/test/widget/page_transitions_test.dart b/packages/flutter/test/widget/page_transitions_test.dart index 9e16b9596d8..7da2b516986 100644 --- a/packages/flutter/test/widget/page_transitions_test.dart +++ b/packages/flutter/test/widget/page_transitions_test.dart @@ -7,7 +7,9 @@ import 'package:flutter/material.dart'; class TestOverlayRoute extends OverlayRoute { @override - List get builders => [ _build ]; + Iterable createOverlayEntries() sync* { + yield new OverlayEntry(builder: _build); + } Widget _build(BuildContext context) => new Text('Overlay'); } @@ -22,7 +24,7 @@ void main() { await tester.pumpWidget(new MaterialApp(routes: routes)); - expect(find.text('Home'), isOnStage); + expect(find.text('Home'), isOnstage); expect(find.text('Settings'), findsNothing); expect(find.text('Overlay'), findsNothing); @@ -32,20 +34,20 @@ void main() { await tester.pump(); - expect(find.text('Home'), isOnStage); - expect(find.text('Settings'), isOffStage); + expect(find.text('Home'), isOnstage); + expect(find.text('Settings', skipOffstage: false), isOffstage); expect(find.text('Overlay'), findsNothing); await tester.pump(const Duration(milliseconds: 16)); - expect(find.text('Home'), isOnStage); - expect(find.text('Settings'), isOnStage); + expect(find.text('Home'), isOnstage); + expect(find.text('Settings'), isOnstage); expect(find.text('Overlay'), findsNothing); await tester.pump(const Duration(seconds: 1)); expect(find.text('Home'), findsNothing); - expect(find.text('Settings'), isOnStage); + expect(find.text('Settings'), isOnstage); expect(find.text('Overlay'), findsNothing); Navigator.push(containerKey2.currentContext, new TestOverlayRoute()); @@ -53,40 +55,40 @@ void main() { await tester.pump(); expect(find.text('Home'), findsNothing); - expect(find.text('Settings'), isOnStage); - expect(find.text('Overlay'), isOnStage); + expect(find.text('Settings'), isOnstage); + expect(find.text('Overlay'), isOnstage); await tester.pump(const Duration(seconds: 1)); expect(find.text('Home'), findsNothing); - expect(find.text('Settings'), isOnStage); - expect(find.text('Overlay'), isOnStage); + expect(find.text('Settings'), isOnstage); + expect(find.text('Overlay'), isOnstage); expect(Navigator.canPop(containerKey2.currentContext), isTrue); Navigator.pop(containerKey2.currentContext); await tester.pump(); expect(find.text('Home'), findsNothing); - expect(find.text('Settings'), isOnStage); + expect(find.text('Settings'), isOnstage); expect(find.text('Overlay'), findsNothing); await tester.pump(const Duration(seconds: 1)); expect(find.text('Home'), findsNothing); - expect(find.text('Settings'), isOnStage); + expect(find.text('Settings'), isOnstage); expect(find.text('Overlay'), findsNothing); expect(Navigator.canPop(containerKey2.currentContext), isTrue); Navigator.pop(containerKey2.currentContext); await tester.pump(); - expect(find.text('Home'), isOnStage); - expect(find.text('Settings'), isOnStage); + expect(find.text('Home'), isOnstage); + expect(find.text('Settings'), isOnstage); expect(find.text('Overlay'), findsNothing); await tester.pump(const Duration(seconds: 1)); - expect(find.text('Home'), isOnStage); + expect(find.text('Home'), isOnstage); expect(find.text('Settings'), findsNothing); expect(find.text('Overlay'), findsNothing); diff --git a/packages/flutter/test/widget/remember_scroll_position_test.dart b/packages/flutter/test/widget/remember_scroll_position_test.dart index 0baf927ba34..b63d6b38e02 100644 --- a/packages/flutter/test/widget/remember_scroll_position_test.dart +++ b/packages/flutter/test/widget/remember_scroll_position_test.dart @@ -2,10 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; class ThePositiveNumbers extends StatelessWidget { + ThePositiveNumbers({ @required this.from }); + final int from; @override Widget build(BuildContext context) { return new ScrollableLazyList( @@ -13,104 +16,110 @@ class ThePositiveNumbers extends StatelessWidget { itemBuilder: (BuildContext context, int start, int count) { List result = new List(); for (int index = start; index < start + count; index += 1) - result.add(new Text('$index', key: new ValueKey(index))); + result.add(new Text('${index + from}', key: new ValueKey(index))); return result; } ); } } +Future performTest(WidgetTester tester, bool maintainState) async { + GlobalKey navigatorKey = new GlobalKey(); + await tester.pumpWidget(new Navigator( + key: navigatorKey, + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/') { + return new MaterialPageRoute( + settings: settings, + builder: (_) => new Container(child: new ThePositiveNumbers(from: 0)), + maintainState: maintainState, + ); + } else if (settings.name == '/second') { + return new MaterialPageRoute( + settings: settings, + builder: (_) => new Container(child: new ThePositiveNumbers(from: 10000)), + maintainState: maintainState, + ); + } + return null; + } + )); + + // we're 600 pixels high, each item is 100 pixels high, scroll position is + // zero, so we should have exactly 6 items, 0..5. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.text('6'), findsNothing); + expect(find.text('10'), findsNothing); + expect(find.text('100'), findsNothing); + + ScrollableState targetState = tester.state(find.byType(Scrollable)); + targetState.scrollTo(1000.0); + await tester.pump(new Duration(seconds: 1)); + + // we're 600 pixels high, each item is 100 pixels high, scroll position is + // 1000, so we should have exactly 6 items, 10..15. + + expect(find.text('0'), findsNothing); + expect(find.text('8'), findsNothing); + expect(find.text('9'), findsNothing); + expect(find.text('10'), findsOneWidget); + expect(find.text('11'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('13'), findsOneWidget); + expect(find.text('14'), findsOneWidget); + expect(find.text('15'), findsOneWidget); + expect(find.text('16'), findsNothing); + expect(find.text('100'), findsNothing); + + navigatorKey.currentState.pushNamed('/second'); + await tester.pump(); // navigating always takes two frames, one to start... + await tester.pump(new Duration(seconds: 1)); // ...and one to end the transition + + // the second list is now visible, starting at 10000 + expect(find.text('10000'), findsOneWidget); + expect(find.text('10001'), findsOneWidget); + expect(find.text('10002'), findsOneWidget); + expect(find.text('10003'), findsOneWidget); + expect(find.text('10004'), findsOneWidget); + expect(find.text('10005'), findsOneWidget); + expect(find.text('10006'), findsNothing); + expect(find.text('10010'), findsNothing); + expect(find.text('10100'), findsNothing); + + navigatorKey.currentState.pop(); + await tester.pump(); // again, navigating always takes two frames + + // Ensure we don't clamp the scroll offset even during the navigation. + // https://github.com/flutter/flutter/issues/4883 + LazyListViewport viewport = tester.firstWidget(find.byType(LazyListViewport)); + expect(viewport.scrollOffset, equals(1000.0)); + + await tester.pump(new Duration(seconds: 1)); + + // we're 600 pixels high, each item is 100 pixels high, scroll position is + // 1000, so we should have exactly 6 items, 10..15. + + expect(find.text('0'), findsNothing); + expect(find.text('8'), findsNothing); + expect(find.text('9'), findsNothing); + expect(find.text('10'), findsOneWidget); + expect(find.text('11'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('13'), findsOneWidget); + expect(find.text('14'), findsOneWidget); + expect(find.text('15'), findsOneWidget); + expect(find.text('16'), findsNothing); + expect(find.text('100'), findsNothing); +} + void main() { testWidgets('whether we remember our scroll position', (WidgetTester tester) async { - GlobalKey navigatorKey = new GlobalKey(); - await tester.pumpWidget(new Navigator( - key: navigatorKey, - onGenerateRoute: (RouteSettings settings) { - if (settings.name == '/') { - return new MaterialPageRoute( - settings: settings, - builder: (_) => new Container(child: new ThePositiveNumbers()) - ); - } else if (settings.name == '/second') { - return new MaterialPageRoute( - settings: settings, - builder: (_) => new Container(child: new ThePositiveNumbers()) - ); - } - return null; - } - )); - - // we're 600 pixels high, each item is 100 pixels high, scroll position is - // zero, so we should have exactly 6 items, 0..5. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('2'), findsOneWidget); - expect(find.text('3'), findsOneWidget); - expect(find.text('4'), findsOneWidget); - expect(find.text('5'), findsOneWidget); - expect(find.text('6'), findsNothing); - expect(find.text('10'), findsNothing); - expect(find.text('100'), findsNothing); - - ScrollableState targetState = tester.state(find.byType(Scrollable)); - targetState.scrollTo(1000.0); - await tester.pump(new Duration(seconds: 1)); - - // we're 600 pixels high, each item is 100 pixels high, scroll position is - // 1000, so we should have exactly 6 items, 10..15. - - expect(find.text('0'), findsNothing); - expect(find.text('8'), findsNothing); - expect(find.text('9'), findsNothing); - expect(find.text('10'), findsOneWidget); - expect(find.text('11'), findsOneWidget); - expect(find.text('12'), findsOneWidget); - expect(find.text('13'), findsOneWidget); - expect(find.text('14'), findsOneWidget); - expect(find.text('15'), findsOneWidget); - expect(find.text('16'), findsNothing); - expect(find.text('100'), findsNothing); - - navigatorKey.currentState.pushNamed('/second'); - await tester.pump(); // navigating always takes two frames - await tester.pump(new Duration(seconds: 1)); - - // same as the first list again - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('2'), findsOneWidget); - expect(find.text('3'), findsOneWidget); - expect(find.text('4'), findsOneWidget); - expect(find.text('5'), findsOneWidget); - expect(find.text('6'), findsNothing); - expect(find.text('10'), findsNothing); - expect(find.text('100'), findsNothing); - - navigatorKey.currentState.pop(); - await tester.pump(); // navigating always takes two frames - - // Ensure we don't clamp the scroll offset even during the navigation. - // https://github.com/flutter/flutter/issues/4883 - LazyListViewport viewport = tester.firstWidget(find.byType(LazyListViewport)); - expect(viewport.scrollOffset, equals(1000.0)); - - await tester.pump(new Duration(seconds: 1)); - - // we're 600 pixels high, each item is 100 pixels high, scroll position is - // 1000, so we should have exactly 6 items, 10..15. - - expect(find.text('0'), findsNothing); - expect(find.text('8'), findsNothing); - expect(find.text('9'), findsNothing); - expect(find.text('10'), findsOneWidget); - expect(find.text('11'), findsOneWidget); - expect(find.text('12'), findsOneWidget); - expect(find.text('13'), findsOneWidget); - expect(find.text('14'), findsOneWidget); - expect(find.text('15'), findsOneWidget); - expect(find.text('16'), findsNothing); - expect(find.text('100'), findsNothing); - + await performTest(tester, true); + await performTest(tester, false); }); } diff --git a/packages/flutter/test/widget/reparent_state_test.dart b/packages/flutter/test/widget/reparent_state_test.dart index c84d9a8a047..e79e867ad7f 100644 --- a/packages/flutter/test/widget/reparent_state_test.dart +++ b/packages/flutter/test/widget/reparent_state_test.dart @@ -327,4 +327,45 @@ void main() { await tester.pump(); expect(log, isEmpty); }); + + testWidgets('Reparenting with multiple moves', (WidgetTester tester) async { + final GlobalKey key1 = new GlobalKey(); + final GlobalKey key2 = new GlobalKey(); + final GlobalKey key3 = new GlobalKey(); + + await tester.pumpWidget( + new Row( + children: [ + new StateMarker( + key: key1, + child: new StateMarker( + key: key2, + child: new StateMarker( + key: key3, + child: new StateMarker(child: new Container(width: 100.0)) + ) + ) + ) + ] + ) + ); + + await tester.pumpWidget( + new Row( + children: [ + new StateMarker( + key: key2, + child: new StateMarker(child: new Container(width: 100.0)) + ), + new StateMarker( + key: key1, + child: new StateMarker( + key: key3, + child: new StateMarker(child: new Container(width: 100.0)) + ) + ), + ] + ) + ); + }); } diff --git a/packages/flutter_test/lib/src/all_elements.dart b/packages/flutter_test/lib/src/all_elements.dart index 077983b0a75..c401d05bd9a 100644 --- a/packages/flutter_test/lib/src/all_elements.dart +++ b/packages/flutter_test/lib/src/all_elements.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; + import 'package:flutter/widgets.dart'; /// Provides an iterable that efficiently returns all the elements @@ -15,13 +17,18 @@ import 'package:flutter/widgets.dart'; /// The same applies to any iterable obtained indirectly through this /// one, for example the results of calling `where` on this iterable /// are also cached. -Iterable collectAllElementsFrom(Element rootElement) { - return new CachingIterable(new _DepthFirstChildIterator(rootElement)); +Iterable collectAllElementsFrom(Element rootElement, { + @required bool skipOffstage +}) { + return new CachingIterable(new _DepthFirstChildIterator(rootElement, skipOffstage)); } class _DepthFirstChildIterator implements Iterator { - _DepthFirstChildIterator(Element rootElement) - : _stack = _reverseChildrenOf(rootElement).toList(); + _DepthFirstChildIterator(Element rootElement, bool skipOffstage) + : skipOffstage = skipOffstage, + _stack = _reverseChildrenOf(rootElement, skipOffstage).toList(); + + final bool skipOffstage; Element _current; @@ -37,14 +44,18 @@ class _DepthFirstChildIterator implements Iterator { _current = _stack.removeLast(); // Stack children in reverse order to traverse first branch first - _stack.addAll(_reverseChildrenOf(_current)); + _stack.addAll(_reverseChildrenOf(_current, skipOffstage)); return true; } - static Iterable _reverseChildrenOf(Element element) { + static Iterable _reverseChildrenOf(Element element, bool skipOffstage) { final List children = []; - element.visitChildren(children.add); + if (skipOffstage) { + element.visitChildrenForSemantics(children.add); + } else { + element.visitChildren(children.add); + } return children.reversed; } } diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 2cd215bebdd..0d985824a4c 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -85,7 +85,7 @@ class WidgetController { /// using [Iterator.moveNext]. Iterable get allElements { TestAsyncUtils.guardSync(); - return collectAllElementsFrom(binding.renderViewElement); + return collectAllElementsFrom(binding.renderViewElement, skipOffstage: false); } /// The matching element in the widget tree. diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index 3b3a7ffe318..590342e38af 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -27,7 +27,10 @@ class CommonFinders { /// Example: /// /// expect(tester, hasWidget(find.text('Back'))); - Finder text(String text) => new _TextFinder(text); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder text(String text, { bool skipOffstage: true }) => new _TextFinder(text, skipOffstage: skipOffstage); /// Looks for widgets that contain a [Text] descendant with `text` /// in it. @@ -41,8 +44,11 @@ class CommonFinders { /// /// // You can find and tap on it like this: /// tester.tap(find.widgetWithText(Button, 'Update')); - Finder widgetWithText(Type widgetType, String text) { - return new _WidgetWithTextFinder(widgetType, text); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder widgetWithText(Type widgetType, String text, { bool skipOffstage: true }) { + return new _WidgetWithTextFinder(widgetType, text, skipOffstage: skipOffstage); } /// Finds widgets by searching for one with a particular [Key]. @@ -50,7 +56,10 @@ class CommonFinders { /// Example: /// /// expect(tester, hasWidget(find.byKey(backKey))); - Finder byKey(Key key) => new _KeyFinder(key); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byKey(Key key, { bool skipOffstage: true }) => new _KeyFinder(key, skipOffstage: skipOffstage); /// Finds widgets by searching for widgets with a particular type. /// @@ -63,7 +72,10 @@ class CommonFinders { /// Example: /// /// expect(tester, hasWidget(find.byType(IconButton))); - Finder byType(Type type) => new _WidgetTypeFinder(type); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byType(Type type, { bool skipOffstage: true }) => new _WidgetTypeFinder(type, skipOffstage: skipOffstage); /// Finds widgets by searching for elements with a particular type. /// @@ -76,7 +88,10 @@ class CommonFinders { /// Example: /// /// expect(tester, hasWidget(find.byElementType(SingleChildRenderObjectElement))); - Finder byElementType(Type type) => new _ElementTypeFinder(type); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byElementType(Type type, { bool skipOffstage: true }) => new _ElementTypeFinder(type, skipOffstage: skipOffstage); /// Finds widgets whose current widget is the instance given by the /// argument. @@ -90,7 +105,10 @@ class CommonFinders { /// /// // You can find and tap on it like this: /// tester.tap(find.byConfig(myButton)); - Finder byConfig(Widget config) => new _ConfigFinder(config); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byConfig(Widget config, { bool skipOffstage: true }) => new _ConfigFinder(config, skipOffstage: skipOffstage); /// Finds widgets using a widget predicate. /// @@ -99,8 +117,11 @@ class CommonFinders { /// expect(tester, hasWidget(find.byWidgetPredicate( /// (Widget widget) => widget is Tooltip && widget.message == 'Back' /// ))); - Finder byWidgetPredicate(WidgetPredicate predicate) { - return new _WidgetPredicateFinder(predicate); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byWidgetPredicate(WidgetPredicate predicate, { bool skipOffstage: true }) { + return new _WidgetPredicateFinder(predicate, skipOffstage: skipOffstage); } /// Finds widgets using an element predicate. @@ -113,14 +134,21 @@ class CommonFinders { /// // (contrast with byElementType, which only returns exact matches) /// (Element element) => element is SingleChildRenderObjectElement /// ))); - Finder byElementPredicate(ElementPredicate predicate) { - return new _ElementPredicateFinder(predicate); + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder byElementPredicate(ElementPredicate predicate, { bool skipOffstage: true }) { + return new _ElementPredicateFinder(predicate, skipOffstage: skipOffstage); } } /// Searches a widget tree and returns nodes that match a particular /// pattern. abstract class Finder { + /// Initialises a Finder. Used by subclasses to initialize the [skipOffstage] + /// property. + Finder({ this.skipOffstage: true }); + /// Describes what the finder is looking for. The description should be /// a brief English noun phrase describing the finder's pattern. String get description; @@ -134,11 +162,19 @@ abstract class Finder { /// function, consider extending [MatchFinder] instead. Iterable apply(Iterable candidates); - // Right now this is hard-coded to just grab the elements from the binding. - // - // One could imagine a world where CommonFinders and Finder can be configured - // to work from a specific subtree, but we'll implement that when it's needed. - static Iterable get _allElements => collectAllElementsFrom(WidgetsBinding.instance.renderViewElement); + /// Whether this finder skips nodes that are offstage. + /// + /// If this is true, then the elements are walked using + /// [Element.visitChildrenForSemantics]. This skips offstage children of + /// [Offstage] widgets, as well as children of inactive [Route]s. + final bool skipOffstage; + + Iterable get _allElements { + return collectAllElementsFrom( + WidgetsBinding.instance.renderViewElement, + skipOffstage: skipOffstage + ); + } Iterable _cachedResult; @@ -171,21 +207,26 @@ abstract class Finder { @override String toString() { + final String additional = skipOffstage ? ' (ignoring offstage widgets)' : ''; final List widgets = evaluate().toList(); final int count = widgets.length; if (count == 0) - return 'zero widgets with $description'; + return 'zero widgets with $description$additional'; if (count == 1) - return 'exactly one widget with $description: ${widgets.single}'; + return 'exactly one widget with $description$additional: ${widgets.single}'; if (count < 4) - return '$count widgets with $description: $widgets'; - return '$count widgets with $description: ${widgets[0]}, ${widgets[1]}, ${widgets[2]}, ...'; + return '$count widgets with $description$additional: $widgets'; + return '$count widgets with $description$additional: ${widgets[0]}, ${widgets[1]}, ${widgets[2]}, ...'; } } /// Searches a widget tree and returns nodes that match a particular /// pattern. abstract class MatchFinder extends Finder { + /// Initialises a predicate-based Finder. Used by subclasses to initialize the + /// [skipOffstage] property. + MatchFinder({ bool skipOffstage: true }) : super(skipOffstage: skipOffstage); + /// Returns true if the given element matches the pattern. /// /// When implementing your own MatchFinder, this is the main method to override. @@ -198,7 +239,7 @@ abstract class MatchFinder extends Finder { } class _TextFinder extends MatchFinder { - _TextFinder(this.text); + _TextFinder(this.text, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final String text; @@ -215,7 +256,7 @@ class _TextFinder extends MatchFinder { } class _WidgetWithTextFinder extends Finder { - _WidgetWithTextFinder(this.widgetType, this.text); + _WidgetWithTextFinder(this.widgetType, this.text, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final Type widgetType; final String text; @@ -249,7 +290,7 @@ class _WidgetWithTextFinder extends Finder { } class _KeyFinder extends MatchFinder { - _KeyFinder(this.key); + _KeyFinder(this.key, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final Key key; @@ -263,7 +304,7 @@ class _KeyFinder extends MatchFinder { } class _WidgetTypeFinder extends MatchFinder { - _WidgetTypeFinder(this.widgetType); + _WidgetTypeFinder(this.widgetType, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final Type widgetType; @@ -277,7 +318,7 @@ class _WidgetTypeFinder extends MatchFinder { } class _ElementTypeFinder extends MatchFinder { - _ElementTypeFinder(this.elementType); + _ElementTypeFinder(this.elementType, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final Type elementType; @@ -291,7 +332,7 @@ class _ElementTypeFinder extends MatchFinder { } class _ConfigFinder extends MatchFinder { - _ConfigFinder(this.config); + _ConfigFinder(this.config, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final Widget config; @@ -305,7 +346,7 @@ class _ConfigFinder extends MatchFinder { } class _WidgetPredicateFinder extends MatchFinder { - _WidgetPredicateFinder(this.predicate); + _WidgetPredicateFinder(this.predicate, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final WidgetPredicate predicate; @@ -319,7 +360,7 @@ class _WidgetPredicateFinder extends MatchFinder { } class _ElementPredicateFinder extends MatchFinder { - _ElementPredicateFinder(this.predicate); + _ElementPredicateFinder(this.predicate, { bool skipOffstage: true }) : super(skipOffstage: skipOffstage); final ElementPredicate predicate; diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index d736c5b65b6..d8e51c3ab0d 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -36,12 +36,19 @@ const Matcher findsOneWidget = const _FindsWidgetMatcher(1, 1); Matcher findsNWidgets(int n) => new _FindsWidgetMatcher(n, n); /// Asserts that the [Finder] locates the a single widget that has at -/// least one [OffStage] widget ancestor. -const Matcher isOffStage = const _IsOffStage(); +/// least one [Offstage] widget ancestor. +/// +/// It's important to use a full finder, since by default finders exclude +/// offstage widgets. +/// +/// Example: +/// +/// expect(find.text('Save', skipOffstage: false), isOffstage); +const Matcher isOffstage = const _IsOffstage(); /// Asserts that the [Finder] locates the a single widget that has no -/// [OffStage] widget ancestors. -const Matcher isOnStage = const _IsOnStage(); +/// [Offstage] widget ancestors. +const Matcher isOnstage = const _IsOnstage(); /// Asserts that the [Finder] locates the a single widget that has at /// least one [Card] widget ancestor. @@ -54,7 +61,8 @@ const Matcher isNotInCard = const _IsNotInCard(); /// Asserts that an object's toString() is a plausible one-line description. /// /// Specifically, this matcher checks that the string does not contains newline -/// characters and does not have leading or trailing whitespace. +/// characters, and does not have leading or trailing whitespace, and is not +/// empty. const Matcher hasOneLineDescription = const _HasOneLineDescription(); class _FindsWidgetMatcher extends Matcher { @@ -66,18 +74,17 @@ class _FindsWidgetMatcher extends Matcher { @override bool matches(Finder finder, Map matchState) { assert(min != null || max != null); + assert(min == null || max == null || min <= max); matchState[Finder] = finder; + int count = 0; + Iterator iterator = finder.evaluate().iterator; if (min != null) { - int count = 0; - Iterator iterator = finder.evaluate().iterator; while (count < min && iterator.moveNext()) count += 1; if (count < min) return false; } if (max != null) { - int count = 0; - Iterator iterator = finder.evaluate().iterator; while (count <= max && iterator.moveNext()) count += 1; if (count > max) @@ -137,9 +144,11 @@ class _FindsWidgetMatcher extends Matcher { } bool _hasAncestorMatching(Finder finder, bool predicate(Widget widget)) { - expect(finder, findsOneWidget); + Iterable nodes = finder.evaluate(); + if (nodes.length != 1) + return false; bool result = false; - finder.evaluate().single.visitAncestorElements((Element ancestor) { + nodes.single.visitAncestorElements((Element ancestor) { if (predicate(ancestor.widget)) { result = true; return false; @@ -153,16 +162,15 @@ bool _hasAncestorOfType(Finder finder, Type targetType) { return _hasAncestorMatching(finder, (Widget widget) => widget.runtimeType == targetType); } -class _IsOffStage extends Matcher { - const _IsOffStage(); +class _IsOffstage extends Matcher { + const _IsOffstage(); @override bool matches(Finder finder, Map matchState) { return _hasAncestorMatching(finder, (Widget widget) { - if (widget.runtimeType != OffStage) - return false; - OffStage offstage = widget; - return offstage.offstage; + if (widget is Offstage) + return widget.offstage; + return false; }); } @@ -170,18 +178,19 @@ class _IsOffStage extends Matcher { Description describe(Description description) => description.add('offstage'); } -class _IsOnStage extends Matcher { - const _IsOnStage(); +class _IsOnstage extends Matcher { + const _IsOnstage(); @override bool matches(Finder finder, Map matchState) { - expect(finder, findsOneWidget); + Iterable nodes = finder.evaluate(); + if (nodes.length != 1) + return false; bool result = true; - finder.evaluate().single.visitAncestorElements((Element ancestor) { + nodes.single.visitAncestorElements((Element ancestor) { Widget widget = ancestor.widget; - if (widget.runtimeType == OffStage) { - OffStage offstage = widget; - result = !offstage.offstage; + if (widget is Offstage) { + result = !widget.offstage; return false; } return true; @@ -219,9 +228,9 @@ class _HasOneLineDescription extends Matcher { @override bool matches(Object object, Map matchState) { String description = object.toString(); - return description.isNotEmpty && - !description.contains('\n') && - description.trim() == description; + return description.isNotEmpty + && !description.contains('\n') + && description.trim() == description; } @override diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 8b96c598992..b9aba1bdd37 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -204,7 +204,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher { )?.target; if (innerTarget == null) return null; - final Element innerTargetElement = collectAllElementsFrom(binding.renderViewElement) + final Element innerTargetElement = collectAllElementsFrom(binding.renderViewElement, skipOffstage: true) .lastWhere((Element element) => element.renderObject == innerTarget); final List candidates = []; innerTargetElement.visitAncestorElements((Element element) { diff --git a/packages/flutter_test/test/widget_tester_test.dart b/packages/flutter_test/test/widget_tester_test.dart index 8e9e2d0e047..6552959751c 100644 --- a/packages/flutter_test/test/widget_tester_test.dart +++ b/packages/flutter_test/test/widget_tester_test.dart @@ -15,7 +15,7 @@ void main() { testWidgets('fails with a descriptive message', (WidgetTester tester) async { TestFailure failure; try { - expect(find.text('foo'), findsOneWidget); + expect(find.text('foo', skipOffstage: false), findsOneWidget); } catch(e) { failure = e; } @@ -38,7 +38,7 @@ void main() { TestFailure failure; try { - expect(find.text('foo'), findsNothing); + expect(find.text('foo', skipOffstage: false), findsNothing); } catch(e) { failure = e; } @@ -50,6 +50,24 @@ void main() { expect(message, contains('Actual: ?:\n')); expect(message, contains('Which: means one was found but none were expected\n')); }); + + testWidgets('fails with a descriptive message when skipping', (WidgetTester tester) async { + await tester.pumpWidget(new Text('foo')); + + TestFailure failure; + try { + expect(find.text('foo'), findsNothing); + } catch(e) { + failure = e; + } + + expect(failure, isNotNull); + String message = failure.message; + + expect(message, contains('Expected: no matching nodes in the widget tree\n')); + expect(message, contains('Actual: ?:\n')); + expect(message, contains('Which: means one was found but none were expected\n')); + }); }); }