CupertinoPageRoute cleanup (#11473)

diff --git a/packages/flutter/lib/src/cupertino/page.dart b/packages/flutter/lib/src/cupertino/page.dart
index ee44919..fbe57df 100644
--- a/packages/flutter/lib/src/cupertino/page.dart
+++ b/packages/flutter/lib/src/cupertino/page.dart
@@ -3,9 +3,12 @@
 // found in the LICENSE file.
 
 import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/rendering.dart';
 import 'package:flutter/widgets.dart';
 
-const double _kMinFlingVelocity = 1.0;  // screen width per second.
+const double _kBackGestureWidth = 20.0;
+const double _kMinFlingVelocity = 1.0; // Screen widths per second.
 
 // Fractional offset from offscreen to the right to fully on screen.
 final FractionalOffsetTween _kRightMiddleTween = new FractionalOffsetTween(
@@ -48,11 +51,11 @@
 
 /// A modal route that replaces the entire screen with an iOS transition.
 ///
-/// The page slides in from the right and exits in reverse.
-/// The page also shifts to the left in parallax when another page enters to cover it.
+/// The page slides in from the right and exits in reverse. The page also shifts
+/// to the left in parallax when another page enters to cover it.
 ///
-/// The page slides in from the bottom and exits in reverse with no parallax effect
-/// for fullscreen dialogs.
+/// The page slides in from the bottom and exits in reverse with no parallax
+/// effect for fullscreen dialogs.
 ///
 /// 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
@@ -60,16 +63,24 @@
 ///
 /// See also:
 ///
-///  * [MaterialPageRoute] for an adaptive [PageRoute] that uses a platform appropriate transition.
+///  * [MaterialPageRoute] for an adaptive [PageRoute] that uses a platform
+///    appropriate transition.
 class CupertinoPageRoute<T> extends PageRoute<T> {
   /// Creates a page route for use in an iOS designed app.
+  ///
+  /// The [builder], [settings], [maintainState], and [fullscreenDialog]
+  /// arguments must not be null.
   CupertinoPageRoute({
     @required this.builder,
     RouteSettings settings: const RouteSettings(),
     this.maintainState: true,
     bool fullscreenDialog: false,
+    this.hostRoute,
   }) : assert(builder != null),
-       assert(opaque),
+       assert(settings != null),
+       assert(maintainState != null),
+       assert(fullscreenDialog != null),
+       assert(opaque), // PageRoute makes it return true.
        super(settings: settings, fullscreenDialog: fullscreenDialog);
 
   /// Builds the primary contents of the route.
@@ -78,6 +89,16 @@
   @override
   final bool maintainState;
 
+  /// The route that owns this one.
+  ///
+  /// The [MaterialPageRoute] creates a [CupertinoPageRoute] to handle iOS-style
+  /// navigation. When this happens, the [MaterialPageRoute] is the [hostRoute]
+  /// of this [CupertinoPageRoute].
+  ///
+  /// The [hostRoute] is responsible for calling [dispose] on the route. When
+  /// there is a [hostRoute], the [CupertinoPageRoute] must not be [install]ed.
+  final PageRoute<T> hostRoute;
+
   @override
   Duration get transitionDuration => const Duration(milliseconds: 350);
 
@@ -85,8 +106,8 @@
   Color get barrierColor => null;
 
   @override
-  bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) {
-    return nextRoute is CupertinoPageRoute<dynamic>;
+  bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) {
+    return previousRoute is CupertinoPageRoute;
   }
 
   @override
@@ -96,63 +117,108 @@
   }
 
   @override
-  void dispose() {
-    _backGestureController?.dispose();
-    // If the route is never installed (i.e. pushed into a Navigator) such as the
-    // case when [MaterialPageRoute] delegates transition building to [CupertinoPageRoute],
-    // don't dispose super.
-    if (overlayEntries.isNotEmpty)
-      super.dispose();
+  void install(OverlayEntry insertionPoint) {
+    assert(() {
+      if (hostRoute == null)
+        return true;
+      throw new FlutterError(
+        'Cannot install a subsidiary route (one with a hostRoute).\n'
+        'This route ($this) cannot be installed, because it has a host route ($hostRoute).'
+      );
+    });
+    super.install(insertionPoint);
   }
 
-  CupertinoBackGestureController _backGestureController;
+  @override
+  void dispose() {
+    _backGestureController?.dispose();
+    _backGestureController = null;
+    super.dispose();
+  }
 
-  /// Support for dismissing this route with a horizontal swipe.
+  _CupertinoBackGestureController _backGestureController;
+
+  /// Whether a pop gesture is currently underway.
+  ///
+  /// This starts returning true when the [startPopGesture] method returns a new
+  /// [NavigationGestureController]. It returns false if that has not yet
+  /// occurred or if the most recent such gesture has completed.
+  ///
+  /// See also:
+  ///
+  ///  * [popGestureEnabled], which returns whether a pop gesture is appropriate
+  ///    in the first place.
+  bool get popGestureInProgress => _backGestureController != null;
+
+  /// Whether a pop gesture will be considered acceptable by [startPopGesture].
+  ///
+  /// This returns true if the user can edge-swipe to a previous route,
+  /// otherwise false.
+  ///
+  /// This will return false if [popGestureInProgress] is true.
+  ///
+  /// This should only be used between frames, not during build.
+  bool get popGestureEnabled {
+    final PageRoute<T> route = hostRoute ?? this;
+    // If there's nothing to go back to, then obviously we don't support
+    // the back gesture.
+    if (route.isFirst)
+      return false;
+    // If the route wouldn't actually pop if we popped it, then the gesture
+    // would be really confusing (or would skip internal routes), so disallow it.
+    if (route.willHandlePopInternally)
+      return false;
+    // If attempts to dismiss this route might be vetoed such as in a page
+    // with forms, then do not allow the user to dismiss the route with a swipe.
+    if (route.hasScopedWillPopCallback)
+      return false;
+    // Fullscreen dialogs aren't dismissable by back swipe.
+    if (fullscreenDialog)
+      return false;
+    // If we're in an animation already, we cannot be manually swiped.
+    if (route.controller.status != AnimationStatus.completed)
+      return false;
+    // If we're in a gesture already, we cannot start another.
+    if (popGestureInProgress)
+      return false;
+    // Looks like a back gesture would be welcome!
+    return true;
+  }
+
+  /// Begin dismissing this route from a horizontal swipe, if appropriate.
   ///
   /// Swiping will be disabled if the page is a fullscreen dialog or if
   /// dismissals can be overriden because a [WillPopCallback] was
   /// defined for the route.
   ///
+  /// When this method decides a pop gesture is appropriate, it returns a
+  /// [CupertinoBackGestureController].
+  ///
   /// See also:
   ///
   ///  * [hasScopedWillPopCallback], which is true if a `willPop` callback
   ///    is defined for this route.
-  @override
-  NavigationGestureController startPopGesture() {
-    return startPopGestureForRoute(this);
+  ///  * [popGestureEnabled], which returns whether a pop gesture is
+  ///    appropriate.
+  ///  * [Route.startPopGesture], which describes the contract that this method
+  ///    must implement.
+  _CupertinoBackGestureController _startPopGesture() {
+    assert(!popGestureInProgress);
+    assert(popGestureEnabled);
+    final PageRoute<T> route = hostRoute ?? this;
+    _backGestureController = new _CupertinoBackGestureController(
+      navigator: route.navigator,
+      controller: route.controller,
+      onEnded: _endPopGesture,
+    );
+    return _backGestureController;
   }
 
-  /// Create a CupertinoBackGestureController using a specific PageRoute.
-  ///
-  /// Used when [MaterialPageRoute] delegates the back gesture to [CupertinoPageRoute]
-  /// since the [CupertinoPageRoute] is not actually inserted into the Navigator.
-  NavigationGestureController startPopGestureForRoute(PageRoute<T> hostRoute) {
-    // If attempts to dismiss this route might be vetoed such as in a page
-    // with forms, then do not allow the user to dismiss the route with a swipe.
-    if (hostRoute.hasScopedWillPopCallback)
-      return null;
-    // Fullscreen dialogs aren't dismissable by back swipe.
-    if (fullscreenDialog)
-      return null;
-    if (hostRoute.controller.status != AnimationStatus.completed)
-      return null;
-    assert(_backGestureController == null);
-    _backGestureController = new CupertinoBackGestureController(
-      navigator: hostRoute.navigator,
-      controller: hostRoute.controller,
-    );
-
-    Function handleBackGestureEnded;
-    handleBackGestureEnded = (AnimationStatus status) {
-      if (status == AnimationStatus.completed) {
-        _backGestureController?.dispose();
-        _backGestureController = null;
-        hostRoute.controller.removeStatusListener(handleBackGestureEnded);
-      }
-    };
-
-    hostRoute.controller.addStatusListener(handleBackGestureEnded);
-    return _backGestureController;
+  void _endPopGesture() {
+    // In practice this only gets called if for some reason popping the route
+    // did not cause this route to get disposed.
+    _backGestureController?.dispose();
+    _backGestureController = null;
   }
 
   @override
@@ -172,20 +238,25 @@
 
   @override
   Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
-    if (fullscreenDialog)
+    if (fullscreenDialog) {
       return new CupertinoFullscreenDialogTransition(
         animation: animation,
         child: child,
       );
-    else
+    } else {
       return new CupertinoPageTransition(
         primaryRouteAnimation: animation,
         secondaryRouteAnimation: secondaryAnimation,
-        child: child,
-        // In the middle of a back gesture drag, let the transition be linear to match finger
-        // motions.
-        linearTransition: _backGestureController != null,
+        // In the middle of a back gesture drag, let the transition be linear to
+        // match finger motions.
+        linearTransition: popGestureInProgress,
+        child: new _CupertinoBackGestureDetector(
+          enabledCallback: () => popGestureEnabled,
+          onStartPopGesture: _startPopGesture,
+          child: child,
+        ),
       );
+    }
   }
 
   @override
@@ -294,49 +365,145 @@
   }
 }
 
+/// This is the widget side of [_CupertinoBackGestureController].
+///
+/// This widget provides a gesture recognizer which, when it determines the
+/// route can be closed with a back gesture, creates the controller and
+/// feeds it the input from the gesture recognizer.
+class _CupertinoBackGestureDetector extends StatefulWidget {
+  const _CupertinoBackGestureDetector({
+    Key key,
+    @required this.enabledCallback,
+    @required this.onStartPopGesture,
+    @required this.child,
+  }) : assert(enabledCallback != null),
+       assert(onStartPopGesture != null),
+       assert(child != null),
+       super(key: key);
+
+  final Widget child;
+
+  final ValueGetter<bool> enabledCallback;
+
+  final ValueGetter<_CupertinoBackGestureController> onStartPopGesture;
+
+  @override
+  _CupertinoBackGestureDetectorState createState() => new _CupertinoBackGestureDetectorState();
+}
+
+class _CupertinoBackGestureDetectorState extends State<_CupertinoBackGestureDetector> {
+  _CupertinoBackGestureController _backGestureController;
+
+  HorizontalDragGestureRecognizer _recognizer;
+
+  @override
+  void initState() {
+    super.initState();
+    _recognizer = new HorizontalDragGestureRecognizer(debugOwner: this)
+      ..onStart = _handleDragStart
+      ..onUpdate = _handleDragUpdate
+      ..onEnd = _handleDragEnd
+      ..onCancel = _handleDragCancel;
+  }
+
+  @override
+  void dispose() {
+    _recognizer.dispose();
+    super.dispose();
+  }
+
+  void _handleDragStart(DragStartDetails details) {
+    assert(mounted);
+    assert(_backGestureController == null);
+    _backGestureController = widget.onStartPopGesture();
+  }
+
+  void _handleDragUpdate(DragUpdateDetails details) {
+    assert(mounted);
+    assert(_backGestureController != null);
+    _backGestureController.dragUpdate(details.primaryDelta / context.size.width);
+  }
+
+  void _handleDragEnd(DragEndDetails details) {
+    assert(mounted);
+    assert(_backGestureController != null);
+    _backGestureController.dragEnd(details.velocity.pixelsPerSecond.dx / context.size.width);
+    _backGestureController = null;
+  }
+
+  void _handleDragCancel() {
+    assert(mounted);
+    // This can be called even if start is not called, paired with the "down" event
+    // that we don't consider here.
+    _backGestureController?.dragEnd(0.0);
+    _backGestureController = null;
+  }
+
+  void _handlePointerDown(PointerDownEvent event) {
+    if (widget.enabledCallback())
+      _recognizer.addPointer(event);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new Stack(
+      fit: StackFit.passthrough,
+      children: <Widget>[
+        widget.child,
+        new Listener(
+          onPointerDown: _handlePointerDown,
+          behavior: HitTestBehavior.translucent,
+          child: new SizedBox(width: _kBackGestureWidth),
+        ),
+      ],
+    );
+  }
+}
+
+
 /// A controller for an iOS-style back gesture.
 ///
-/// Uses a drag gesture to control the route's transition animation progress.
-class CupertinoBackGestureController extends NavigationGestureController {
+/// This is created by a [CupertinoPageRoute] in response from a gesture caught
+/// by a [_CupertinoBackGestureDetector] widget, which then also feeds it input
+/// from the gesture. It controls the animation controller owned by the route,
+/// based on the input provided by the gesture detector.
+class _CupertinoBackGestureController {
   /// Creates a controller for an iOS-style back gesture.
   ///
   /// The [navigator] and [controller] arguments must not be null.
-  CupertinoBackGestureController({
-    @required NavigatorState navigator,
+  _CupertinoBackGestureController({
+    @required this.navigator,
     @required this.controller,
-  }) : assert(controller != null),
-       super(navigator);
+    @required this.onEnded,
+  }) : assert(navigator != null),
+       assert(controller != null),
+       assert(onEnded != null) {
+    navigator.didStartUserGesture();
+  }
+
+  /// The navigator that this object is controlling.
+  final NavigatorState navigator;
 
   /// The animation controller that the route uses to drive its transition
   /// animation.
   final AnimationController controller;
 
-  @override
-  void dispose() {
-    controller.removeStatusListener(_handleStatusChanged);
-    super.dispose();
-  }
+  final VoidCallback onEnded;
 
-  @override
+  bool _animating = false;
+
+  /// The drag gesture has changed by [fractionalDelta]. The total range of the
+  /// drag should be 0.0 to 1.0.
   void dragUpdate(double delta) {
-    // This assert can be triggered the Scaffold is reparented out of the route
-    // associated with this gesture controller and continues to feed it events.
-    // TODO(abarth): Change the ownership of the gesture controller so that the
-    // object feeding it these events (e.g., the Scaffold) is responsible for
-    // calling dispose on it as well.
-    assert(controller != null);
     controller.value -= delta;
   }
 
-  @override
-  bool dragEnd(double velocity) {
-    // This assert can be triggered the Scaffold is reparented out of the route
-    // associated with this gesture controller and continues to feed it events.
-    // TODO(abarth): Change the ownership of the gesture controller so that the
-    // object feeding it these events (e.g., the Scaffold) is responsible for
-    // calling dispose on it as well.
-    assert(controller != null);
-
+  /// The drag gesture has ended with a horizontal motion of
+  /// [fractionalVelocity] as a fraction of screen width per second.
+  void dragEnd(double velocity) {
+    // Fling in the appropriate direction.
+    // AnimationController.fling is guaranteed to
+    // take at least one frame.
     if (velocity.abs() >= _kMinFlingVelocity) {
       controller.fling(velocity: -velocity);
     } else if (controller.value <= 0.5) {
@@ -344,18 +511,28 @@
     } else {
       controller.fling(velocity: 1.0);
     }
+    assert(controller.isAnimating);
+    assert(controller.status != AnimationStatus.completed);
+    assert(controller.status != AnimationStatus.dismissed);
 
     // Don't end the gesture until the transition completes.
-    final AnimationStatus status = controller.status;
-    _handleStatusChanged(status);
-    controller?.addStatusListener(_handleStatusChanged);
-
-    return (status == AnimationStatus.reverse || status == AnimationStatus.dismissed);
+    _animating = true;
+    controller.addStatusListener(_handleStatusChanged);
   }
 
   void _handleStatusChanged(AnimationStatus status) {
+    assert(_animating);
+    controller.removeStatusListener(_handleStatusChanged);
+    _animating = false;
     if (status == AnimationStatus.dismissed)
-      navigator.pop();
+      navigator.pop(); // this will cause the route to get disposed, which will dispose us
+    onEnded(); // this will call dispose if popping the route failed to do so
+  }
+
+  void dispose() {
+    if (_animating)
+      controller.removeStatusListener(_handleStatusChanged);
+    navigator.didStopUserGesture();
   }
 }
 
diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart
index 4d9a371..67b6cf9 100644
--- a/packages/flutter/lib/src/material/page.dart
+++ b/packages/flutter/lib/src/material/page.dart
@@ -72,19 +72,28 @@
   /// Builds the primary contents of the route.
   final WidgetBuilder builder;
 
+  @override
+  final bool maintainState;
+
   /// A delegate PageRoute to which iOS themed page operations are delegated to.
   /// It's lazily created on first use.
-  CupertinoPageRoute<T> _internalCupertinoPageRoute;
   CupertinoPageRoute<T> get _cupertinoPageRoute {
+    assert(_useCupertinoTransitions);
     _internalCupertinoPageRoute ??= new CupertinoPageRoute<T>(
       builder: builder, // Not used.
       fullscreenDialog: fullscreenDialog,
+      hostRoute: this,
     );
     return _internalCupertinoPageRoute;
   }
+  CupertinoPageRoute<T> _internalCupertinoPageRoute;
 
-  @override
-  final bool maintainState;
+  /// Whether we should currently be using Cupertino transitions. This is true
+  /// if the theme says we're on iOS, or if we're in an active gesture.
+  bool get _useCupertinoTransitions {
+    return _internalCupertinoPageRoute?.popGestureInProgress == true
+        || Theme.of(navigator.context).platform == TargetPlatform.iOS;
+  }
 
   @override
   Duration get transitionDuration => const Duration(milliseconds: 300);
@@ -93,8 +102,8 @@
   Color get barrierColor => null;
 
   @override
-  bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) {
-    return nextRoute is MaterialPageRoute<dynamic> || nextRoute is CupertinoPageRoute<dynamic>;
+  bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) {
+    return (previousRoute is MaterialPageRoute || previousRoute is CupertinoPageRoute);
   }
 
   @override
@@ -110,23 +119,6 @@
     super.dispose();
   }
 
-  /// Support for dismissing this route with a horizontal swipe is enabled
-  /// for [TargetPlatform.iOS]. If attempts to dismiss this route might be
-  /// vetoed because a [WillPopCallback] was defined for the route then the
-  /// platform-specific back gesture is disabled.
-  ///
-  /// See also:
-  ///
-  ///  * [CupertinoPageRoute] that backs the gesture for iOS.
-  ///  * [hasScopedWillPopCallback], which is true if a `willPop` callback
-  ///    is defined for this route.
-  @override
-  NavigationGestureController startPopGesture() {
-    return Theme.of(navigator.context).platform == TargetPlatform.iOS
-        ? _cupertinoPageRoute.startPopGestureForRoute(this)
-        : null;
-  }
-
   @override
   Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
     final Widget result = builder(context);
@@ -144,12 +136,12 @@
 
   @override
   Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
-    if (Theme.of(context).platform == TargetPlatform.iOS) {
+    if (_useCupertinoTransitions) {
       return _cupertinoPageRoute.buildTransitions(context, animation, secondaryAnimation, child);
     } else {
       return new _MountainViewPageTransition(
         routeAnimation: animation,
-        child: child
+        child: child,
       );
     }
   }
diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart
index 72e72d4..426b01a 100644
--- a/packages/flutter/lib/src/material/scaffold.dart
+++ b/packages/flutter/lib/src/material/scaffold.dart
@@ -23,8 +23,6 @@
 const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 200);
 final Tween<double> _kFloatingActionButtonTurnTween = new Tween<double>(begin: -0.125, end: 0.0);
 
-const double _kBackGestureWidth = 20.0;
-
 enum _ScaffoldSlot {
   body,
   appBar,
@@ -717,40 +715,6 @@
     }
   }
 
-  final GlobalKey _backGestureKey = new GlobalKey();
-  NavigationGestureController _backGestureController;
-
-  bool _shouldHandleBackGesture() {
-    assert(mounted);
-    return Theme.of(context).platform == TargetPlatform.iOS && Navigator.canPop(context);
-  }
-
-  void _handleDragStart(DragStartDetails details) {
-    assert(mounted);
-    _backGestureController = Navigator.of(context).startPopGesture();
-  }
-
-  void _handleDragUpdate(DragUpdateDetails details) {
-    assert(mounted);
-    _backGestureController?.dragUpdate(details.primaryDelta / context.size.width);
-  }
-
-  void _handleDragEnd(DragEndDetails details) {
-    assert(mounted);
-    final bool willPop = _backGestureController?.dragEnd(details.velocity.pixelsPerSecond.dx / context.size.width) ?? false;
-    if (willPop)
-      _currentBottomSheet?.close();
-    _backGestureController = null;
-  }
-
-  void _handleDragCancel() {
-    assert(mounted);
-    final bool willPop = _backGestureController?.dragEnd(0.0) ?? false;
-    if (willPop)
-      _currentBottomSheet?.close();
-    _backGestureController = null;
-  }
-
 
   // INTERNALS
 
@@ -887,25 +851,6 @@
           child: widget.drawer,
         )
       ));
-    } else if (_shouldHandleBackGesture()) {
-      assert(!hasDrawer);
-      // Add a gesture for navigating back.
-      children.add(new LayoutId(
-        id: _ScaffoldSlot.drawer,
-        child: new Align(
-          alignment: FractionalOffset.centerLeft,
-          child: new GestureDetector(
-            key: _backGestureKey,
-            onHorizontalDragStart: _handleDragStart,
-            onHorizontalDragUpdate: _handleDragUpdate,
-            onHorizontalDragEnd: _handleDragEnd,
-            onHorizontalDragCancel: _handleDragCancel,
-            behavior: HitTestBehavior.translucent,
-            excludeFromSemantics: true,
-            child: new Container(width: _kBackGestureWidth)
-          )
-        )
-      ));
     }
 
     return new _ScaffoldScope(
diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart
index 0b9e5f0..49a5213 100644
--- a/packages/flutter/lib/src/widgets/framework.dart
+++ b/packages/flutter/lib/src/widgets/framework.dart
@@ -3113,6 +3113,8 @@
           'resulting object.\n'
           'The size getter was called for the following element:\n'
           '  $this\n'
+          'The associated render sliver was:\n'
+          '  ${renderObject.toStringShallow("\n  ")}'
         );
       }
       if (renderObject is! RenderBox) {
@@ -3124,10 +3126,12 @@
           'and extracting its size manually.\n'
           'The size getter was called for the following element:\n'
           '  $this\n'
+          'The associated render object was:\n'
+          '  ${renderObject.toStringShallow("\n  ")}'
         );
       }
       final RenderBox box = renderObject;
-      if (!box.hasSize || box.debugNeedsLayout) {
+      if (!box.hasSize) {
         throw new FlutterError(
           'Cannot get size from a render object that has not been through layout.\n'
           'The size of this render object has not yet been determined because '
@@ -3137,6 +3141,24 @@
           'the size and position of the render objects during layout.\n'
           'The size getter was called for the following element:\n'
           '  $this\n'
+          'The render object from which the size was to be obtained was:\n'
+          '  ${box.toStringShallow("\n  ")}'
+        );
+      }
+      if (box.debugNeedsLayout) {
+        throw new FlutterError(
+          'Cannot get size from a render object that has been marked dirty for layout.\n'
+          'The size of this render object is ambiguous because this render object has '
+          'been modified since it was last laid out, which typically means that the size '
+          'getter was called too early in the pipeline (e.g., during the build phase) '
+          'before the framework has determined the size and position of the render '
+          'objects during layout.\n'
+          'The size getter was called for the following element:\n'
+          '  $this\n'
+          'The render object from which the size was to be obtained was:\n'
+          '  ${box.toStringShallow("\n  ")}\n'
+          'Consider using debugPrintMarkNeedsLayoutStacks to determine why the render '
+          'object in question is dirty, if you did not expect this.'
         );
       }
       return true;
diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart
index 1ba60b5..c0e747e 100644
--- a/packages/flutter/lib/src/widgets/navigator.dart
+++ b/packages/flutter/lib/src/widgets/navigator.dart
@@ -178,29 +178,9 @@
   @mustCallSuper
   @protected
   void dispose() {
-    assert(() {
-      if (navigator == null) {
-        throw new FlutterError(
-          '$runtimeType.dipose() called more than once.\n'
-          'A given route cannot be disposed more than once.'
-        );
-      }
-      return true;
-    });
     _navigator = null;
   }
 
-  /// If the route's transition can be popped via a user gesture (e.g. the iOS
-  /// back gesture), this should return a controller object that can be used to
-  /// control the transition animation's progress. Otherwise, it should return
-  /// null.
-  ///
-  /// If attempts to dismiss this route might be vetoed, for example because
-  /// a [WillPopCallback] was defined for the route, then it may make sense
-  /// to disable the pop gesture. For example, the iOS back gesture is disabled
-  /// when [ModalRoute.hasScopedWillPopCallback] is true.
-  NavigationGestureController startPopGesture() => null;
-
   /// Whether this route is the top-most route on the navigator.
   ///
   /// If this is true, then [isActive] is also true.
@@ -288,56 +268,18 @@
   /// The [Navigator] removed `route`.
   void didRemove(Route<dynamic> route, Route<dynamic> previousRoute) { }
 
-  /// The [Navigator] is being controlled by a user gesture.
+  /// The [Navigator]'s routes are being moved by a user gesture.
   ///
-  /// Used for the iOS back gesture.
+  /// For example, this is called when an iOS back gesture starts, and is used
+  /// to disabled hero animations during such interactions.
   void didStartUserGesture() { }
 
   /// User gesture is no longer controlling the [Navigator].
+  ///
+  /// Paired with an earlier call to [didStartUserGesture].
   void didStopUserGesture() { }
 }
 
-/// Interface describing an object returned by the [Route.startPopGesture]
-/// method, allowing the route's transition animations to be controlled by a
-/// drag or other user gesture.
-abstract class NavigationGestureController {
-  /// Configures the NavigationGestureController and tells the given [Navigator] that
-  /// a gesture has started.
-  NavigationGestureController(this._navigator)
-    : assert(_navigator != null) {
-    // Disable Hero transitions until the gesture is complete.
-    _navigator.didStartUserGesture();
-  }
-
-  /// The navigator that this object is controlling.
-  @protected
-  NavigatorState get navigator => _navigator;
-  NavigatorState _navigator;
-
-  /// Release the resources used by this object. The object is no longer usable
-  /// after this method is called.
-  ///
-  /// Must be called when the gesture is done.
-  ///
-  /// Calling this method notifies the navigator that the gesture has completed.
-  @mustCallSuper
-  void dispose() {
-    _navigator.didStopUserGesture();
-    _navigator = null;
-  }
-
-  /// The drag gesture has changed by [fractionalDelta]. The total range of the
-  /// drag should be 0.0 to 1.0.
-  void dragUpdate(double fractionalDelta);
-
-  /// The drag gesture has ended with a horizontal motion of
-  /// [fractionalVelocity] as a fraction of screen width per second.
-  ///
-  /// Returns true if the gesture will complete (i.e. a back gesture will
-  /// result in a pop).
-  bool dragEnd(double fractionalVelocity);
-}
-
 /// Signature for the [Navigator.popUntil] predicate argument.
 typedef bool RoutePredicate(Route<dynamic> route);
 
@@ -1326,33 +1268,35 @@
     return _history.length > 1 || _history[0].willHandlePopInternally;
   }
 
-  /// Starts a gesture that results in popping the navigator.
-  NavigationGestureController startPopGesture() {
-    if (canPop())
-      return _history.last.startPopGesture();
-    return null;
-  }
-
-  /// Whether a gesture controlled by a [NavigationGestureController] is currently in progress.
-  bool get userGestureInProgress => _userGestureInProgress;
-  // TODO(mpcomplete): remove this bool when we fix
-  // https://github.com/flutter/flutter/issues/5577
-  bool _userGestureInProgress = false;
+  /// Whether a route is currently being manipulated by the user, e.g.
+  /// as during an iOS back gesture.
+  bool get userGestureInProgress => _userGesturesInProgress > 0;
+  int _userGesturesInProgress = 0;
 
   /// The navigator is being controlled by a user gesture.
   ///
-  /// Used for the iOS back gesture.
+  /// For example, called when the user beings an iOS back gesture.
+  ///
+  /// When the gesture finishes, call [didStopUserGesture].
   void didStartUserGesture() {
-    _userGestureInProgress = true;
-    for (NavigatorObserver observer in widget.observers)
-      observer.didStartUserGesture();
+    _userGesturesInProgress += 1;
+    if (_userGesturesInProgress == 1) {
+      for (NavigatorObserver observer in widget.observers)
+        observer.didStartUserGesture();
+    }
   }
 
-  /// A user gesture is no longer controlling the navigator.
+  /// A user gesture completed.
+  ///
+  /// Notifies the navigator that a gesture regarding which the navigator was
+  /// previously notified with [didStartUserGesture] has completed.
   void didStopUserGesture() {
-    _userGestureInProgress = false;
-    for (NavigatorObserver observer in widget.observers)
-      observer.didStopUserGesture();
+    assert(_userGesturesInProgress > 0);
+    _userGesturesInProgress -= 1;
+    if (_userGesturesInProgress == 0) {
+      for (NavigatorObserver observer in widget.observers)
+        observer.didStopUserGesture();
+    }
   }
 
   final Set<int> _activePointers = new Set<int>();
diff --git a/packages/flutter/lib/src/widgets/pages.dart b/packages/flutter/lib/src/widgets/pages.dart
index a37e8b1..9277471 100644
--- a/packages/flutter/lib/src/widgets/pages.dart
+++ b/packages/flutter/lib/src/widgets/pages.dart
@@ -32,10 +32,10 @@
   bool get barrierDismissible => false;
 
   @override
-  bool canTransitionTo(TransitionRoute<dynamic> nextRoute) => nextRoute is PageRoute<dynamic>;
+  bool canTransitionTo(TransitionRoute<dynamic> nextRoute) => nextRoute is PageRoute;
 
   @override
-  bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) => nextRoute is PageRoute<dynamic>;
+  bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => previousRoute is PageRoute;
 
   @override
   AnimationController createAnimationController() {
diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart
index b359984..7d48046 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -105,6 +105,7 @@
   /// this route from the previous one, and back to the previous route from this
   /// one.
   AnimationController createAnimationController() {
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     final Duration duration = transitionDuration;
     assert(duration != null && duration >= Duration.ZERO);
     return new AnimationController(
@@ -118,6 +119,7 @@
   /// the transition controlled by the animation controller created by
   /// [createAnimationController()].
   Animation<double> createAnimation() {
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     assert(_controller != null);
     return _controller.view;
   }
@@ -157,21 +159,26 @@
 
   @override
   void install(OverlayEntry insertionPoint) {
+    assert(!_transitionCompleter.isCompleted, 'Cannot install a $runtimeType after disposing it.');
     _controller = createAnimationController();
-    assert(_controller != null);
+    assert(_controller != null, '$runtimeType.createAnimationController() returned null.');
     _animation = createAnimation();
-    assert(_animation != null);
+    assert(_animation != null, '$runtimeType.createAnimation() returned null.');
     super.install(insertionPoint);
   }
 
   @override
   TickerFuture didPush() {
+    assert(_controller != null, '$runtimeType.didPush called before calling install() or after calling dispose().');
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     _animation.addStatusListener(_handleStatusChanged);
     return _controller.forward();
   }
 
   @override
   void didReplace(Route<dynamic> oldRoute) {
+    assert(_controller != null, '$runtimeType.didReplace called before calling install() or after calling dispose().');
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     if (oldRoute is TransitionRoute<dynamic>)
       _controller.value = oldRoute._controller.value;
     _animation.addStatusListener(_handleStatusChanged);
@@ -180,6 +187,8 @@
 
   @override
   bool didPop(T result) {
+    assert(_controller != null, '$runtimeType.didPop called before calling install() or after calling dispose().');
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     _result = result;
     _controller.reverse();
     return super.didPop(result);
@@ -187,12 +196,16 @@
 
   @override
   void didPopNext(Route<dynamic> nextRoute) {
+    assert(_controller != null, '$runtimeType.didPopNext called before calling install() or after calling dispose().');
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     _updateSecondaryAnimation(nextRoute);
     super.didPopNext(nextRoute);
   }
 
   @override
   void didChangeNext(Route<dynamic> nextRoute) {
+    assert(_controller != null, '$runtimeType.didChangeNext called before calling install() or after calling dispose().');
+    assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
     _updateSecondaryAnimation(nextRoute);
     super.didChangeNext(nextRoute);
   }
@@ -236,11 +249,12 @@
   ///
   /// Subclasses can override this method to restrict the set of routes they
   /// need to coordinate transitions with.
-  bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) => true;
+  bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => true;
 
   @override
   void dispose() {
-    _controller.dispose();
+    assert(!_transitionCompleter.isCompleted, 'Cannot dispose a $runtimeType twice.');
+    _controller?.dispose();
     _transitionCompleter.complete(_result);
     super.dispose();
   }
@@ -502,19 +516,24 @@
   /// ```dart
   /// ModalRoute<dynamic> route = ModalRoute.of(context);
   /// ```
+  ///
+  /// The given [BuildContext] will be rebuilt if the state of the route changes
+  /// (specifically, if [Route.isCurrent] or [Route.canPop] change value).
   static ModalRoute<dynamic> of(BuildContext context) {
     final _ModalScopeStatus widget = context.inheritFromWidgetOfExactType(_ModalScopeStatus);
     return widget?.route;
   }
 
+  /// Schedule a call to [buildTransitions].
+  ///
   /// Whenever you need to change internal state for a ModalRoute object, make
-  /// the change in a function that you pass to setState(), as in:
+  /// the change in a function that you pass to [setState], as in:
   ///
   /// ```dart
   /// setState(() { myState = newValue });
   /// ```
   ///
-  /// If you just change the state directly without calling setState(), then the
+  /// If you just change the state directly without calling [setState], then the
   /// route will not be scheduled for rebuilding, meaning that its rendering
   /// will not be updated.
   @protected
@@ -537,7 +556,8 @@
   static RoutePredicate withName(String name) {
     return (Route<dynamic> route) {
       return !route.willHandlePopInternally
-        && route is ModalRoute && route.settings.name == name;
+          && route is ModalRoute
+          && route.settings.name == name;
     };
   }
 
@@ -545,21 +565,40 @@
 
   /// Override this method to build the primary content of this route.
   ///
-  /// * [context] The context in which the route is being built.
-  /// * [animation] The animation for this route's transition. When entering,
-  ///   the animation runs forward from 0.0 to 1.0. When exiting, this animation
-  ///   runs backwards from 1.0 to 0.0.
-  /// * [secondaryAnimation] The animation for the route being pushed on top of
-  ///   this route. This animation lets this route coordinate with the entrance
-  ///   and exit transition of routes pushed on top of this route.
+  /// The arguments have the following meanings:
+  ///
+  ///  * `context`: The context in which the route is being built.
+  ///  * [animation]: The animation for this route's transition. When entering,
+  ///    the animation runs forward from 0.0 to 1.0. When exiting, this animation
+  ///    runs backwards from 1.0 to 0.0.
+  ///  * [secondaryAnimation]: The animation for the route being pushed on top of
+  ///    this route. This animation lets this route coordinate with the entrance
+  ///    and exit transition of routes pushed on top of this route.
+  ///
+  /// This method is called when the route is first built, and rarely
+  /// thereafter. In particular, it is not called again when the route's state
+  /// changes. For a builder that is called every time the route's state
+  /// changes, consider [buildTransitions]. For widgets that change their
+  /// behavior when the route's state changes, consider [ModalRoute.of] to
+  /// obtain a reference to the route; this will cause the widget to be rebuilt
+  /// each time the route changes state.
+  ///
+  /// In general, [buildPage] should be used to build the page contents, and
+  /// [buildTransitions] for the widgets that change as the page is brought in
+  /// and out of view. Avoid using [buildTransitions] for content that never
+  /// changes; building such content once from [buildPage] is more efficient.
   Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation);
 
   /// Override this method to wrap the [child] with one or more transition
   /// widgets that define how the route arrives on and leaves the screen.
   ///
-  /// By default, the child is not wrapped in any transition widgets.
+  /// By default, the child (which contains the widget returned by [buildPage])
+  /// is not wrapped in any transition widgets.
   ///
-  /// The buildTransitions method is typically used to define transitions
+  /// The [buildTransitions] method, in contrast to [buildPage], is called each
+  /// time the [Route]'s state changes (e.g. the value of [canPop]).
+  ///
+  /// The [buildTransitions] method is typically used to define transitions
   /// that animate the new topmost route's comings and goings. When the
   /// [Navigator] pushes a route on the top of its stack, the new route's
   /// primary [animation] runs from 0.0 to 1.0. When the Navigator pops the
@@ -603,11 +642,11 @@
   /// );
   ///```
   ///
-  /// We've used [PageRouteBuilder] to demonstrate the buildTransitions method
-  /// here. The body of an override of the buildTransitions method would be
+  /// We've used [PageRouteBuilder] to demonstrate the [buildTransitions] method
+  /// here. The body of an override of the [buildTransitions] method would be
   /// defined in the same way.
   ///
-  /// When the Navigator pushes a route on the top of its stack, the
+  /// When the [Navigator] pushes a route on the top of its stack, the
   /// [secondaryAnimation] can be used to define how the route that was on
   /// the top of the stack leaves the screen. Similarly when the topmost route
   /// is popped, the secondaryAnimation can be used to define how the route
@@ -617,7 +656,7 @@
   /// secondaryAnimation for the route below it runs from 1.0 to 0.0.
   ///
   /// The example below adds a transition that's driven by the
-  /// secondaryAnimation. When this route disappears because a new route has
+  /// [secondaryAnimation]. When this route disappears because a new route has
   /// been pushed on top of it, it translates in the opposite direction of
   /// the new route. Likewise when the route is exposed because the topmost
   /// route has been popped off.
@@ -643,18 +682,26 @@
   ///       ),
   ///     );
   ///   }
-  ///```
+  /// ```
   ///
-  /// In practice the secondaryAnimation is used pretty rarely.
+  /// In practice the `secondaryAnimation` is used pretty rarely.
   ///
-  ///  * [context] The context in which the route is being built.
-  ///  * [animation] When the [Navigator] pushes a route on the top of its stack,
-  ///    the new route's primary [animation] runs from 0.0 to 1.0. When the Navigator
+  /// The arguments to this method are as follows:
+  ///
+  ///  * `context`: The context in which the route is being built.
+  ///  * [animation]: When the [Navigator] pushes a route on the top of its stack,
+  ///    the new route's primary [animation] runs from 0.0 to 1.0. When the [Navigator]
   ///    pops the topmost route this animation runs from 1.0 to 0.0.
-  ///  * [secondaryAnimation] When the Navigator pushes a new route
-  ///    on the top of its stack, the old topmost route's secondaryAnimation
-  ///    runs from 0.0 to 1.0.  When the Navigator pops the topmost route, the
-  ///    secondaryAnimation for the route below it runs from 1.0 to 0.0.
+  ///  * [secondaryAnimation]: When the Navigator pushes a new route
+  ///    on the top of its stack, the old topmost route's [secondaryAnimation]
+  ///    runs from 0.0 to 1.0.  When the [Navigator] pops the topmost route, the
+  ///    [secondaryAnimation] for the route below it runs from 1.0 to 0.0.
+  ///  * `child`, the page contents.
+  ///
+  /// See also:
+  ///
+  ///  * [buildPage], which is used to describe the actual contents of the page,
+  ///    and whose result is passed to the `child` argument of this method.
   Widget buildTransitions(
       BuildContext context,
       Animation<double> animation,
@@ -837,9 +884,9 @@
   ///
   /// See also:
   ///
-  /// * [Form], which provides an `onWillPop` callback that uses this mechanism.
-  /// * [addScopedWillPopCallback], which adds callback to the list
-  ///   checked by [willPop].
+  ///  * [Form], which provides an `onWillPop` callback that uses this mechanism.
+  ///  * [addScopedWillPopCallback], which adds callback to the list
+  ///    checked by [willPop].
   void removeScopedWillPopCallback(WillPopCallback callback) {
     assert(_scopeKey.currentState != null);
     _scopeKey.currentState.removeWillPopCallback(callback);
@@ -851,10 +898,16 @@
   /// supported by [MaterialPageRoute] for [TargetPlatform.iOS].
   /// If a pop might be vetoed, then the back gesture is disabled.
   ///
+  /// The [buildTransitions] method will not be called again if this changes,
+  /// since it can change during the build as descendants of the route add or
+  /// remove callbacks.
+  ///
   /// See also:
   ///
-  /// * [addScopedWillPopCallback], which adds a callback.
-  /// * [removeScopedWillPopCallback], which removes a callback.
+  ///  * [addScopedWillPopCallback], which adds a callback.
+  ///  * [removeScopedWillPopCallback], which removes a callback.
+  ///  * [willHandlePopInternally], which reports on another reason why
+  ///    a pop might be vetoed.
   @protected
   bool get hasScopedWillPopCallback {
     return _scopeKey.currentState == null || _scopeKey.currentState._willPopCallbacks.isNotEmpty;
diff --git a/packages/flutter/test/material/page_test.dart b/packages/flutter/test/material/page_test.dart
index 239f0c5..f20eee0 100644
--- a/packages/flutter/test/material/page_test.dart
+++ b/packages/flutter/test/material/page_test.dart
@@ -272,6 +272,73 @@
     expect(tester.getTopLeft(find.text('Page 2')), const Offset(100.0, 0.0));
   });
 
+  testWidgets('back gesture while OS changes', (WidgetTester tester) async {
+    final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+      '/': (BuildContext context) => new Material(
+        child: new FlatButton(
+          child: new Text('PUSH'),
+          onPressed: () { Navigator.of(context).pushNamed('/b'); },
+        ),
+      ),
+      '/b': (BuildContext context) => new Container(child: new Text('HELLO')),
+    };
+    await tester.pumpWidget(
+      new MaterialApp(
+        theme: new ThemeData(platform: TargetPlatform.iOS),
+        routes: routes,
+      ),
+    );
+    await tester.tap(find.text('PUSH'));
+    expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
+    expect(find.text('PUSH'), findsNothing);
+    expect(find.text('HELLO'), findsOneWidget);
+    final Offset helloPosition1 = tester.getCenter(find.text('HELLO'));
+    final TestGesture gesture = await tester.startGesture(const Offset(2.5, 300.0));
+    await tester.pump(const Duration(milliseconds: 20));
+    await gesture.moveBy(const Offset(100.0, 0.0));
+    expect(find.text('PUSH'), findsNothing);
+    expect(find.text('HELLO'), findsOneWidget);
+    await tester.pump(const Duration(milliseconds: 20));
+    expect(find.text('PUSH'), findsOneWidget);
+    expect(find.text('HELLO'), findsOneWidget);
+    final Offset helloPosition2 = tester.getCenter(find.text('HELLO'));
+    expect(helloPosition1.dx, lessThan(helloPosition2.dx));
+    expect(helloPosition1.dy, helloPosition2.dy);
+    expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.iOS);
+    await tester.pumpWidget(
+      new MaterialApp(
+        theme: new ThemeData(platform: TargetPlatform.android),
+        routes: routes,
+      ),
+    );
+    // Now we have to let the theme animation run through.
+    // This takes three frames (including the first one above):
+    //  1. Start the Theme animation. It's at t=0 so everything else is identical.
+    //  2. Start any animations that are informed by the Theme, for example, the
+    //     DefaultTextStyle, on the first frame that the theme is not at t=0. In
+    //     this case, it's at t=1.0 of the theme animation, so this is also the
+    //     frame in which the theme animation ends.
+    //  3. End all the other animations.
+    expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
+    expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.android);
+    final Offset helloPosition3 = tester.getCenter(find.text('HELLO'));
+    expect(helloPosition3, helloPosition2);
+    expect(find.text('PUSH'), findsOneWidget);
+    expect(find.text('HELLO'), findsOneWidget);
+    await gesture.moveBy(const Offset(100.0, 0.0));
+    await tester.pump(const Duration(milliseconds: 20));
+    expect(find.text('PUSH'), findsOneWidget);
+    expect(find.text('HELLO'), findsOneWidget);
+    final Offset helloPosition4 = tester.getCenter(find.text('HELLO'));
+    expect(helloPosition3.dx, lessThan(helloPosition4.dx));
+    expect(helloPosition3.dy, helloPosition4.dy);
+    await gesture.moveBy(const Offset(500.0, 0.0));
+    await gesture.up();
+    expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
+    expect(find.text('PUSH'), findsOneWidget);
+    expect(find.text('HELLO'), findsNothing);
+  });
+
   testWidgets('test no back gesture on iOS fullscreen dialogs', (WidgetTester tester) async {
     await tester.pumpWidget(
       new MaterialApp(
diff --git a/packages/flutter/test/widgets/page_transitions_test.dart b/packages/flutter/test/widgets/page_transitions_test.dart
index b47a99c..7b18ff0 100644
--- a/packages/flutter/test/widgets/page_transitions_test.dart
+++ b/packages/flutter/test/widgets/page_transitions_test.dart
@@ -268,24 +268,39 @@
     expect(find.text('Home'), findsNothing);
     expect(find.text('Sheet'), isOnstage);
 
+    // Drag from left edge to invoke the gesture. We should go back.
+    TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0));
+    await gesture.moveBy(const Offset(500.0, 0.0));
+    await gesture.up();
+    await tester.pump();
+    await tester.pump(const Duration(seconds: 1));
+
+    Navigator.pushNamed(containerKey1.currentContext, '/sheet');
+
+    await tester.pump();
+    await tester.pump(const Duration(seconds: 1));
+
+    expect(find.text('Home'), findsNothing);
+    expect(find.text('Sheet'), isOnstage);
+
     // Show the bottom sheet.
     final PersistentBottomSheetTestState sheet = containerKey2.currentState;
     sheet.showBottomSheet();
 
     await tester.pump(const Duration(seconds: 1));
 
-    // Drag from left edge to invoke the gesture.
-    final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0));
+    // Drag from left edge to invoke the gesture. Nothing should happen.
+    gesture = await tester.startGesture(const Offset(5.0, 100.0));
     await gesture.moveBy(const Offset(500.0, 0.0));
     await gesture.up();
     await tester.pump();
     await tester.pump(const Duration(seconds: 1));
 
-    expect(find.text('Home'), isOnstage);
-    expect(find.text('Sheet'), findsNothing);
+    expect(find.text('Home'), findsNothing);
+    expect(find.text('Sheet'), isOnstage);
 
-    // Sheet called setState and didn't crash.
-    expect(sheet.setStateCalled, isTrue);
+    // Sheet did not call setState (since the gesture did nothing).
+    expect(sheet.setStateCalled, isFalse);
   });
 
   testWidgets('Test completed future', (WidgetTester tester) async {