diff --git a/packages/flutter/lib/src/animation/animations.dart b/packages/flutter/lib/src/animation/animations.dart index 12921f435c..3756654fe5 100644 --- a/packages/flutter/lib/src/animation/animations.dart +++ b/packages/flutter/lib/src/animation/animations.dart @@ -533,3 +533,75 @@ class TrainHoppingAnimation extends Animation return '$currentTrain\u27A9$runtimeType(no next)'; } } + +/// An interface for combining multiple Animations. Subclasses need only +/// implement the `value` getter to control how the child animations are +/// combined. Can be chained to combine more than 2 animations. +/// +/// For example, to create an animation that is the sum of two others, subclass +/// this class and define `T get value = first.value + second.value;` +abstract class CompoundAnimation extends Animation + with AnimationLazyListenerMixin, AnimationLocalListenersMixin, AnimationLocalStatusListenersMixin { + /// Creates a CompoundAnimation. Both arguments must be non-null. Either can + /// be a CompoundAnimation itself to combine multiple animations. + CompoundAnimation({ + this.first, + this.next, + }) { + assert(first != null); + assert(next != null); + } + + /// The first sub-animation. Its status takes precedence if neither are + /// animating. + final Animation first; + + /// The second sub-animation. + final Animation next; + + @override + void didStartListening() { + first.addListener(_maybeNotifyListeners); + first.addStatusListener(_maybeNotifyStatusListeners); + next.addListener(_maybeNotifyListeners); + next.addStatusListener(_maybeNotifyStatusListeners); + } + + @override + void didStopListening() { + first.removeListener(_maybeNotifyListeners); + first.removeStatusListener(_maybeNotifyStatusListeners); + next.removeListener(_maybeNotifyListeners); + next.removeStatusListener(_maybeNotifyStatusListeners); + } + + @override + AnimationStatus get status { + // If one of the sub-animations is moving, use that status. Otherwise, + // default to `first`. + if (next.status == AnimationStatus.forward || next.status == AnimationStatus.reverse) + return next.status; + return first.status; + } + + @override + String toString() { + return '$runtimeType($first, $next)'; + } + + AnimationStatus _lastStatus; + void _maybeNotifyStatusListeners(AnimationStatus _) { + if (this.status != _lastStatus) { + _lastStatus = this.status; + notifyStatusListeners(this.status); + } + } + + T _lastValue; + void _maybeNotifyListeners() { + if (this.value != _lastValue) { + _lastValue = this.value; + notifyListeners(); + } + } +} diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index f77711bc72..4f02ed1a46 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -5,20 +5,23 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'material.dart'; +import 'theme.dart'; -final FractionalOffsetTween _kMaterialPageTransitionTween = new FractionalOffsetTween( - begin: FractionalOffset.bottomLeft, - end: FractionalOffset.topLeft -); +// Used for Android and Fuchsia. +class _MountainViewPageTransition extends AnimatedWidget { + static final FractionalOffsetTween _kTween = new FractionalOffsetTween( + begin: FractionalOffset.bottomLeft, + end: FractionalOffset.topLeft + ); -class _MaterialPageTransition extends AnimatedWidget { - _MaterialPageTransition({ + _MountainViewPageTransition({ Key key, Animation animation, this.child }) : super( key: key, - animation: _kMaterialPageTransitionTween.animate(new CurvedAnimation( + animation: _kTween.animate(new CurvedAnimation( parent: animation, // The route's linear 0.0 - 1.0 animation. curve: Curves.fastOutSlowIn ) @@ -36,6 +39,108 @@ class _MaterialPageTransition extends AnimatedWidget { } } +// Used for iOS. +class _CupertinoPageTransition extends AnimatedWidget { + static final FractionalOffsetTween _kTween = new FractionalOffsetTween( + begin: FractionalOffset.topRight, + end: -FractionalOffset.topRight + ); + + _CupertinoPageTransition({ + Key key, + Animation animation, + this.child + }) : super( + key: key, + animation: _kTween.animate(new CurvedAnimation( + parent: animation, + curve: new _CupertinoTransitionCurve() + ) + )); + + final Widget child; + + @override + Widget build(BuildContext context) { + // TODO(ianh): tell the transform to be un-transformed for hit testing + // but not while being controlled by a gesture. + return new SlideTransition( + position: animation, + child: new Material( + elevation: 6, + child: child + ) + ); + } +} + +class AnimationMean extends CompoundAnimation { + AnimationMean({ + Animation left, + Animation right, + }) : super(first: left, next: right); + + @override + double get value => (first.value + next.value) / 2.0; +} + +// Custom curve for iOS page transitions. The halfway point is when the page +// is fully on-screen. 0.0 is fully off-screen to the right. 1.0 is off-screen +// to the left. +class _CupertinoTransitionCurve extends Curve { + _CupertinoTransitionCurve(); + + @override + double transform(double t) { + if (t > 0.5) + return (t - 0.5) / 3.0 + 0.5; + return t; + } +} + +// This class responds to drag gestures to control the route's transition +// animation progress. Used for iOS back gesture. +class _CupertinoBackGestureController extends NavigationGestureController { + _CupertinoBackGestureController({ + NavigatorState navigator, + this.controller, + this.onDisposed, + }) : super(navigator); + + AnimationController controller; + VoidCallback onDisposed; + + @override + void dispose() { + super.dispose(); + onDisposed(); + controller.removeStatusListener(handleStatusChanged); + controller = null; + } + + @override + void dragUpdate(double delta) { + controller.value -= delta; + } + + @override + void dragEnd() { + if (controller.value <= 0.5) { + navigator.pop(); + } else { + controller.forward(); + } + // Don't end the gesture until the transition completes. + handleStatusChanged(controller.status); + controller?.addStatusListener(handleStatusChanged); + } + + void handleStatusChanged(AnimationStatus status) { + if (status == AnimationStatus.dismissed || status == AnimationStatus.completed) + dispose(); + } +} + /// A modal route that replaces the entire screen with a material design transition. /// /// The entrance transition for the page slides the page upwards and fades it @@ -64,7 +169,28 @@ class MaterialPageRoute extends PageRoute { Color get barrierColor => null; @override - bool canTransitionFrom(TransitionRoute nextRoute) => false; + bool canTransitionFrom(TransitionRoute nextRoute) { + return nextRoute is MaterialPageRoute; + } + + @override + void dispose() { + super.dispose(); + backGestureController?.dispose(); + } + + _CupertinoBackGestureController backGestureController; + + @override + NavigationGestureController startPopGesture(NavigatorState navigator) { + assert(backGestureController == null); + backGestureController = new _CupertinoBackGestureController( + navigator: navigator, + controller: controller, + onDisposed: () { backGestureController = null; } + ); + return backGestureController; + } @override Widget buildPage(BuildContext context, Animation animation, Animation forwardAnimation) { @@ -83,10 +209,28 @@ class MaterialPageRoute extends PageRoute { @override Widget buildTransitions(BuildContext context, Animation animation, Animation forwardAnimation, Widget child) { - return new _MaterialPageTransition( - animation: animation, - child: child - ); + // TODO(mpcomplete): This hack prevents the previousRoute from animating + // when we pop(). Remove once we fix this bug: + // https://github.com/flutter/flutter/issues/5577 + if (!Navigator.of(context).userGestureInProgress) + forwardAnimation = kAlwaysDismissedAnimation; + + ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.fuchsia: + case TargetPlatform.android: + return new _MountainViewPageTransition( + animation: animation, + child: child + ); + case TargetPlatform.iOS: + return new _CupertinoPageTransition( + animation: new AnimationMean(left: animation, right: forwardAnimation), + child: child + ); + } + + return null; } @override diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 4aa437d2c6..603c87d739 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -22,6 +22,8 @@ const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be de const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 200); final Tween _kFloatingActionButtonTurnTween = new Tween(begin: -0.125, end: 0.0); +const double _kBackGestureWidth = 20.0; + /// The Scaffold's appbar is the toolbar, bottom, and the "flexible space" /// that's stacked behind them. The Scaffold's appBarBehavior defines how /// its layout responds to scrolling the application's body. @@ -689,6 +691,34 @@ class ScaffoldState extends State { ); } + // IOS-specific back gesture. + + final GlobalKey _backGestureKey = new GlobalKey(); + NavigationGestureController _backGestureController; + + bool _shouldHandleBackGesture() { + return Theme.of(context).platform == TargetPlatform.iOS && Navigator.canPop(context); + } + + void _handleDragStart(DragStartDetails details) { + _backGestureController = Navigator.of(context).startPopGesture(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + final RenderBox box = context.findRenderObject(); + _backGestureController?.dragUpdate(details.primaryDelta / box.size.width); + } + + void _handleDragEnd(DragEndDetails details) { + _backGestureController?.dragEnd(); + _backGestureController = null; + } + + void _handleDragCancel() { + _backGestureController?.dragEnd(); + _backGestureController = null; + } + @override Widget build(BuildContext context) { EdgeInsets padding = MediaQuery.of(context).padding; @@ -772,6 +802,24 @@ class ScaffoldState extends State { child: config.drawer ) )); + } else if (_shouldHandleBackGesture()) { + // 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) + ) + ) + )); } EdgeInsets appPadding = (config.appBarBehavior != AppBarBehavior.anchor) ? EdgeInsets.zero : padding; diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index 6820b34971..ced275d76f 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -467,8 +467,21 @@ class HeroController extends NavigatorObserver { } } + // Disable Hero animations while a user gesture is controlling the navigation. + bool _questsEnabled = true; + + @override + void didStartUserGesture() { + _questsEnabled = false; + } + + @override + void didStopUserGesture() { + _questsEnabled = true; + } + void _checkForHeroQuest() { - if (_from != null && _to != null && _from != _to) { + if (_from != null && _to != null && _from != _to && _questsEnabled) { _to.offstage = _to.animation.status != AnimationStatus.completed; WidgetsBinding.instance.addPostFrameCallback(_updateQuest); } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index ba530512ac..a88b67af56 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -80,6 +80,13 @@ abstract class Route { /// is replaced, or if the navigator itself is disposed). void dispose() { } + // 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. + NavigationGestureController startPopGesture(NavigatorState navigator) { + return null; + } + /// Whether this route is the top-most route on the navigator. /// /// If this is true, then [isActive] is also true. @@ -142,6 +149,39 @@ class NavigatorObserver { /// The [Navigator] popped the given route. void didPop(Route route, Route previousRoute) { } + + /// The [Navigator] is being controlled by a user gesture. Used for the + /// iOS back gesture. + void didStartUserGesture() { } + + /// User gesture is no longer controlling the [Navigator]. + void didStopUserGesture() { } +} + +// An interface to be implemented by the Route, allowing its transition +// animation to be controlled by a drag. +abstract class NavigationGestureController { + NavigationGestureController(this._navigator) { + // Disable Hero transitions until the gesture is complete. + _navigator.didStartUserGesture(); + } + + // Must be called when the gesture is done. + 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. + void dragEnd(); + + @protected + NavigatorState get navigator => _navigator; + NavigatorState _navigator; } /// Signature for the [Navigator.popUntil] predicate argument. @@ -460,6 +500,27 @@ class NavigatorState extends State { return _history.length > 1 || _history[0].willHandlePopInternally; } + NavigationGestureController startPopGesture() { + if (canPop()) + return _history.last.startPopGesture(this); + return null; + } + + // TODO(mpcomplete): remove this bool when we fix + // https://github.com/flutter/flutter/issues/5577 + bool _userGestureInProgress = false; + bool get userGestureInProgress => _userGestureInProgress; + + void didStartUserGesture() { + _userGestureInProgress = true; + config.observer?.didStartUserGesture(); + } + + void didStopUserGesture() { + _userGestureInProgress = false; + config.observer?.didStopUserGesture(); + } + final Set _activePointers = new Set(); void _handlePointerDown(PointerDownEvent event) { diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index bdb12abcb5..3494a0c320 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -109,6 +109,9 @@ abstract class TransitionRoute extends OverlayRoute { /// forward transition. Animation get animation => _animation; Animation _animation; + + @protected + AnimationController get controller => _controller; AnimationController _controller; /// Called to create the animation controller that will drive the transitions to