Commit 02c21447 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

CupertinoPageRoute cleanup (#11473)

parent 35496d00
......@@ -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 @@ final DecorationTween _kGradientShadowTween = new DecorationTween(
/// 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 @@ final DecorationTween _kGradientShadowTween = new DecorationTween(
///
/// 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 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
@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 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
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
......@@ -95,66 +116,111 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
return nextRoute is CupertinoPageRoute && !nextRoute.fullscreenDialog;
}
@override
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);
}
@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();
_backGestureController = null;
super.dispose();
}
CupertinoBackGestureController _backGestureController;
_CupertinoBackGestureController _backGestureController;
/// Support for dismissing this route with a horizontal swipe.
/// Whether a pop gesture is currently underway.
///
/// 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.
/// 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:
///
/// * [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
/// in the first place.
bool get popGestureInProgress => _backGestureController != null;
/// Create a CupertinoBackGestureController using a specific PageRoute.
/// Whether a pop gesture will be considered acceptable by [startPopGesture].
///
/// Used when [MaterialPageRoute] delegates the back gesture to [CupertinoPageRoute]
/// since the [CupertinoPageRoute] is not actually inserted into the Navigator.
NavigationGestureController startPopGestureForRoute(PageRoute<T> hostRoute) {
/// 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 (hostRoute.hasScopedWillPopCallback)
return null;
if (route.hasScopedWillPopCallback)
return false;
// 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);
}
};
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;
}
hostRoute.controller.addStatusListener(handleBackGestureEnded);
/// 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.
/// * [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;
}
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
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
final Widget result = builder(context);
......@@ -172,20 +238,25 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
@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 @@ class CupertinoFullscreenDialogTransition extends StatelessWidget {
}
}
/// 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 @@ class CupertinoBackGestureController extends NavigationGestureController {
} 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();
}
}
......
......@@ -72,19 +72,28 @@ class MaterialPageRoute<T> extends PageRoute<T> {
/// 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 @@ class MaterialPageRoute<T> extends PageRoute<T> {
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 @@ class MaterialPageRoute<T> extends PageRoute<T> {
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 @@ class MaterialPageRoute<T> extends PageRoute<T> {
@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,
);
}
}
......
......@@ -23,8 +23,6 @@ const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be de
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 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
}
}
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 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
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(
......
......@@ -3113,6 +3113,8 @@ abstract class Element extends DiagnosticableTree implements BuildContext {
'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 @@ abstract class Element extends DiagnosticableTree implements BuildContext {
'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 @@ abstract class Element extends DiagnosticableTree implements BuildContext {
'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;
......
......@@ -178,29 +178,9 @@ abstract class Route<T> {
@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,54 +268,16 @@ class NavigatorObserver {
/// 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].
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);
/// Paired with an earlier call to [didStartUserGesture].
void didStopUserGesture() { }
}
/// Signature for the [Navigator.popUntil] predicate argument.
......@@ -1326,33 +1268,35 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
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>();
......
......@@ -32,10 +32,10 @@ abstract class PageRoute<T> extends ModalRoute<T> {
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() {
......
......@@ -105,6 +105,7 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
/// 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 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
/// 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 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
@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 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
@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 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
@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 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
///
/// 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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// ```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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// 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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// );
///```
///
/// 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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// 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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// ),
/// );
/// }
///```
/// ```
///
/// In practice the `secondaryAnimation` is used pretty rarely.
///
/// In practice the secondaryAnimation is used pretty rarely.
/// 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
/// * `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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
///
/// 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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// 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;
......
......@@ -272,6 +272,73 @@ void main() {
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(
......
......@@ -268,24 +268,39 @@ void main() {
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 {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment