Commit f1d5fd8c authored by Matt Perry's avatar Matt Perry Committed by GitHub

Simple version of the iOS back gesture. (#5512)

Doesn't do any of the fancy effects. Just lets the user control the
back transition by sliding from the left, like a drawer. Hero
transitions are disabled during the gesture.

BUG=https://github.com/flutter/flutter/issues/4817
parent 7d0b9289
......@@ -533,3 +533,75 @@ class TrainHoppingAnimation extends Animation<double>
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<T> extends Animation<T>
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<T> first;
/// The second sub-animation.
final Animation<T> 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();
}
}
}
......@@ -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<double> 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<double> 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<double> {
AnimationMean({
Animation<double> left,
Animation<double> 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<T> extends PageRoute<T> {
Color get barrierColor => null;
@override
bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) => false;
bool canTransitionFrom(TransitionRoute<dynamic> nextRoute) {
return nextRoute is MaterialPageRoute<dynamic>;
}
@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<double> animation, Animation<double> forwardAnimation) {
......@@ -83,10 +209,28 @@ class MaterialPageRoute<T> extends PageRoute<T> {
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> 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
......
......@@ -22,6 +22,8 @@ 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;
/// 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<Scaffold> {
);
}
// 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<Scaffold> {
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;
......
......@@ -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);
}
......
......@@ -80,6 +80,13 @@ abstract class Route<T> {
/// 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<dynamic> route, Route<dynamic> 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<Navigator> {
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<int> _activePointers = new Set<int>();
void _handlePointerDown(PointerDownEvent event) {
......
......@@ -109,6 +109,9 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
/// forward transition.
Animation<double> get animation => _animation;
Animation<double> _animation;
@protected
AnimationController get controller => _controller;
AnimationController _controller;
/// Called to create the animation controller that will drive the transitions to
......
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