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 {