Commit ea6bf470 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

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.
parent 43d0eeb8
......@@ -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<T> extends PageRoute<T> {
/// Creates a page route for use in a material design app.
MaterialPageRoute({
this.builder,
Completer<T> 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<T> extends PageRoute<T> {
/// Builds the primary contents of the route.
final WidgetBuilder builder;
@override
final bool maintainState;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
......
......@@ -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();
......
......@@ -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
......
......@@ -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 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 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.
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<ChildType extends RenderObject> implem
_child.detach();
}
@override
void redepthChildren() {
if (_child != null)
redepthChild(_child);
}
@override
void visitChildren(RenderObjectVisitor visitor) {
if (_child != null)
......
......@@ -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<RenderBox> {
class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, 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<RenderBox
RenderProxyBox([RenderBox child = null]) {
this.child = child;
}
}
/// Implementation of [RenderProxyBox].
///
/// This class can be used as a mixin for situations where the proxying behavior
/// of [RenderProxyBox] is desired but inheriting from [RenderProxyBox] is
/// impractical (e.g. because you want to mix in other classes as well).
// TODO(ianh): Remove this class once https://github.com/dart-lang/sdk/issues/15101 is fixed
abstract class RenderProxyBoxMixin implements RenderBox, RenderObjectWithChildMixin<RenderBox> {
@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) {
......
......@@ -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({
......
......@@ -199,13 +199,13 @@ class StackParentData extends ContainerBoxParentDataMixin<RenderBox> {
}
}
/// 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
......
......@@ -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<String> description) {
super.debugFillDescription(description);
description.add('ignoring: $ignoring');
if (ignoringSemantics != null)
description.add('ignoringSemantics: $ignoringSemantics');
}
}
/// A widget that absorbs pointers during hit testing.
......
......@@ -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<T extends RenderObject> 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.
......
......@@ -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<Widget> widgets) {
Set<Key> keySet = new HashSet<Key>();
for (Widget widget in widgets) {
......
......@@ -103,8 +103,8 @@ abstract class Route<T> {
/// 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").
......
......@@ -20,7 +20,7 @@ const Color _kTransparent = const Color(0x00000000);
/// A route that displays widgets in the [Navigator]'s [Overlay].
abstract class OverlayRoute<T> extends Route<T> {
/// Subclasses should override this getter to return the builders for the overlay.
List<WidgetBuilder> get builders;
Iterable<OverlayEntry> createOverlayEntries();
/// The entries this route has placed in the overlay.
@override
......@@ -30,8 +30,7 @@ abstract class OverlayRoute<T> extends Route<T> {
@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<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// be transparent.
Color get barrierColor;
/// Whether the route should remain in memory when it is inactive. If this is
/// true, then the route is maintained, so that any futures it is holding from
/// the next route will properly resolve when the next route pops. If this is
/// not necessary, this can be set to false to allow the framework to entirely
/// discard the route's widget hierarchy when it is not visible.
bool get maintainState;
// The API for _ModalScope and HeroController
......@@ -654,10 +660,10 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
}
@override
List<WidgetBuilder> get builders => <WidgetBuilder>[
_buildModalBarrier,
_buildModalScope
];
Iterable<OverlayEntry> 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<T> extends ModalRoute<T> {
@override
bool get opaque => false;
@override
bool get maintainState => true;
@override
void didChangeNext(Route<dynamic> nextRoute) {
assert(nextRoute is! PageRoute<dynamic>);
......
......@@ -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: <String, WidgetBuilder> {
......@@ -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);
......
......@@ -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; }
......
......@@ -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));
......
......@@ -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<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (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'));
expect(find.text('X'), findsNothing);
expect(find.text('Y'), findsOneWidget);
await tester.pump();
expect(find.text('X'), findsOneWidget);
expect(find.text('Y'), findsOneWidget);
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'), findsOneWidget);
expect(find.text('Y'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
expect(find.text('X'), findsOneWidget);
expect(find.text('Y'), findsNothing);
expect(find.text('Y', skipOffstage: false), findsNothing);
});
testWidgets('Navigator.of fails gracefully when not found in context', (WidgetTester tester) async {
......
......@@ -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<T> extends PageRoute<T> {
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<T> extends PageRoute<T> {
@override
Color get barrierColor => null;
@override
bool get maintainState => false;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> 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
});
}
......@@ -7,7 +7,9 @@ import 'package:flutter/material.dart';
class TestOverlayRoute extends OverlayRoute<Null> {
@override
List<WidgetBuilder> get builders => <WidgetBuilder>[ _build ];
Iterable<OverlayEntry> 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);
......
......@@ -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<Widget> result = new List<Widget>();
for (int index = start; index < start + count; index += 1)
result.add(new Text('$index', key: new ValueKey<int>(index)));
result.add(new Text('${index + from}', key: new ValueKey<int>(index)));
return result;
}
);
}
}
void main() {
testWidgets('whether we remember our scroll position', (WidgetTester tester) async {
GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();
await tester.pumpWidget(new Navigator(
key: navigatorKey,
onGenerateRoute: (RouteSettings settings) {
if (settings.name == '/') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: new ThePositiveNumbers())
);
} else if (settings.name == '/second') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: new ThePositiveNumbers())
);
}
return null;
Future<Null> performTest(WidgetTester tester, bool maintainState) async {
GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();
await tester.pumpWidget(new Navigator(
key: navigatorKey,
onGenerateRoute: (RouteSettings settings) {
if (settings.name == '/') {
return new MaterialPageRoute<Null>(
settings: settings,
builder: (_) => new Container(child: new ThePositiveNumbers(from: 0)),
maintainState: maintainState,
);
} else if (settings.name == '/second') {
return new MaterialPageRoute<Null>(
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);
// 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));
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.
// 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);
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));
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
// 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);
// 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(); // navigating always takes two frames
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));
// 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));
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.
// 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);
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 {
await performTest(tester, true);
await performTest(tester, false);
});
}
......@@ -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: <Widget>[
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: <Widget>[
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))
)
),
]
)
);
});
}
......@@ -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<Element> collectAllElementsFrom(Element rootElement) {
return new CachingIterable<Element>(new _DepthFirstChildIterator(rootElement));
Iterable<Element> collectAllElementsFrom(Element rootElement, {
@required bool skipOffstage
}) {
return new CachingIterable<Element>(new _DepthFirstChildIterator(rootElement, skipOffstage));
}
class _DepthFirstChildIterator implements Iterator<Element> {
_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<Element> {
_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<Element> _reverseChildrenOf(Element element) {
static Iterable<Element> _reverseChildrenOf(Element element, bool skipOffstage) {
final List<Element> children = <Element>[];
element.visitChildren(children.add);
if (skipOffstage) {
element.visitChildrenForSemantics(children.add);
} else {
element.visitChildren(children.add);
}
return children.reversed;
}
}
......@@ -85,7 +85,7 @@ class WidgetController {
/// using [Iterator.moveNext].
Iterable<Element> get allElements {
TestAsyncUtils.guardSync();
return collectAllElementsFrom(binding.renderViewElement);
return collectAllElementsFrom(binding.renderViewElement, skipOffstage: false);
}
/// The matching element in the widget tree.
......
......@@ -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<Element> apply(Iterable<Element> 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<Element> 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<Element> get _allElements {
return collectAllElementsFrom(
WidgetsBinding.instance.renderViewElement,
skipOffstage: skipOffstage
);
}
Iterable<Element> _cachedResult;
......@@ -171,21 +207,26 @@ abstract class Finder {
@override
String toString() {
final String additional = skipOffstage ? ' (ignoring offstage widgets)' : '';
final List<Element> 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;
......
......@@ -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<dynamic, dynamic> matchState) {
assert(min != null || max != null);
assert(min == null || max == null || min <= max);
matchState[Finder] = finder;
int count = 0;
Iterator<Element> iterator = finder.evaluate().iterator;
if (min != null) {
int count = 0;
Iterator<Element> iterator = finder.evaluate().iterator;
while (count < min && iterator.moveNext())
count += 1;
if (count < min)
return false;
}
if (max != null) {
int count = 0;
Iterator<Element> 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<Element> 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<dynamic, dynamic> 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<dynamic, dynamic> matchState) {
expect(finder, findsOneWidget);
Iterable<Element> 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<dynamic, dynamic> matchState) {
String description = object.toString();
return description.isNotEmpty &&
!description.contains('\n') &&
description.trim() == description;
return description.isNotEmpty
&& !description.contains('\n')
&& description.trim() == description;
}
@override
......
......@@ -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<Element> candidates = <Element>[];
innerTargetElement.visitAncestorElements((Element element) {
......
......@@ -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: ?:<exactly one widget with text "foo": Text("foo")>\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: ?:<exactly one widget with text "foo" (ignoring offstage widgets): Text("foo")>\n'));
expect(message, contains('Which: means one was found but none were expected\n'));
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment