Unverified Commit d166a8d8 authored by xster's avatar xster Committed by GitHub

Make sure everything in the Cupertino page transition can be linear when back swiping (#28629)

parent 1c0f8254
...@@ -180,22 +180,19 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -180,22 +180,19 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
return nextRoute is CupertinoPageRoute && !nextRoute.fullscreenDialog; return nextRoute is CupertinoPageRoute && !nextRoute.fullscreenDialog;
} }
@override /// True if an iOS-style back swipe pop gesture is currently underway for [route].
void dispose() { ///
_popGestureInProgress.remove(this); /// This just check the route's [NavigatorState.userGestureInProgress].
super.dispose();
}
/// True if a Cupertino pop gesture is currently underway for [route].
/// ///
/// See also: /// See also:
/// ///
/// * [popGestureEnabled], which returns true if a user-triggered pop gesture /// * [popGestureEnabled], which returns true if a user-triggered pop gesture
/// would be allowed. /// would be allowed.
static bool isPopGestureInProgress(PageRoute<dynamic> route) => _popGestureInProgress.contains(route); static bool isPopGestureInProgress(PageRoute<dynamic> route) {
static final Set<PageRoute<dynamic>> _popGestureInProgress = <PageRoute<dynamic>>{}; return route.navigator.userGestureInProgress;
}
/// True if a Cupertino pop gesture is currently underway for this route. /// True if an iOS-style back swipe pop gesture is currently underway for this route.
/// ///
/// See also: /// See also:
/// ///
...@@ -233,10 +230,15 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -233,10 +230,15 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
if (route.fullscreenDialog) if (route.fullscreenDialog)
return false; return false;
// If we're in an animation already, we cannot be manually swiped. // If we're in an animation already, we cannot be manually swiped.
if (route.controller.status != AnimationStatus.completed) if (route.animation.status != AnimationStatus.completed)
return false;
// If we're being popped into, we also cannot be swiped until the pop above
// it completes. This translates to our secondary animation being
// dismissed.
if (route.secondaryAnimation.status != AnimationStatus.dismissed)
return false; return false;
// If we're in a gesture already, we cannot start another. // If we're in a gesture already, we cannot start another.
if (_popGestureInProgress.contains(route)) if (isPopGestureInProgress(route))
return false; return false;
// Looks like a back gesture would be welcome! // Looks like a back gesture would be welcome!
...@@ -266,9 +268,7 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -266,9 +268,7 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
// gesture is detected. The returned controller handles all of the subsequent // gesture is detected. The returned controller handles all of the subsequent
// drag events. // drag events.
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) { static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
assert(!_popGestureInProgress.contains(route));
assert(_isPopGestureEnabled(route)); assert(_isPopGestureEnabled(route));
_popGestureInProgress.add(route);
_CupertinoBackGestureController<T> backController; _CupertinoBackGestureController<T> backController;
backController = _CupertinoBackGestureController<T>( backController = _CupertinoBackGestureController<T>(
...@@ -277,7 +277,6 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -277,7 +277,6 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
onEnded: () { onEnded: () {
backController?.dispose(); backController?.dispose();
backController = null; backController = null;
_popGestureInProgress.remove(route);
}, },
); );
return backController; return backController;
...@@ -313,9 +312,12 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -313,9 +312,12 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
return CupertinoPageTransition( return CupertinoPageTransition(
primaryRouteAnimation: animation, primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation, secondaryRouteAnimation: secondaryAnimation,
// Check if the route has an animation that's currently participating
// in a back swipe gesture.
//
// In the middle of a back gesture drag, let the transition be linear to // In the middle of a back gesture drag, let the transition be linear to
// match finger motions. // match finger motions.
linearTransition: _popGestureInProgress.contains(route), linearTransition: isPopGestureInProgress(route),
child: _CupertinoBackGestureDetector<T>( child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route), enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route), onStartPopGesture: () => _startPopGesture<T>(route),
...@@ -354,27 +356,37 @@ class CupertinoPageTransition extends StatelessWidget { ...@@ -354,27 +356,37 @@ class CupertinoPageTransition extends StatelessWidget {
@required this.child, @required this.child,
@required bool linearTransition, @required bool linearTransition,
}) : assert(linearTransition != null), }) : assert(linearTransition != null),
_primaryPositionAnimation = (linearTransition ? primaryRouteAnimation : _primaryPositionAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
// The curves below have been rigorously derived from plots of native // The curves below have been rigorously derived from plots of native
// iOS animation frames. Specifically, a video was taken of a page // iOS animation frames. Specifically, a video was taken of a page
// transition animation and the distance in each frame that the page // transition animation and the distance in each frame that the page
// moved was measured. A best fit bezier curve was the fitted to the // moved was measured. A best fit bezier curve was the fitted to the
// point set, which is linearToEaseIn. Conversely, easeInToLinear is the // point set, which is linearToEaseIn. Conversely, easeInToLinear is the
// reflection over the origin of linearToEaseIn. // reflection over the origin of linearToEaseIn.
CurvedAnimation(
parent: primaryRouteAnimation, parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut, curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear, reverseCurve: Curves.easeInToLinear,
) )
).drive(_kRightMiddleTween), ).drive(_kRightMiddleTween),
_secondaryPositionAnimation = CurvedAnimation( _secondaryPositionAnimation =
(linearTransition
? secondaryRouteAnimation
: CurvedAnimation(
parent: secondaryRouteAnimation, parent: secondaryRouteAnimation,
curve: Curves.linearToEaseOut, curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear, reverseCurve: Curves.easeInToLinear,
)
).drive(_kMiddleLeftTween), ).drive(_kMiddleLeftTween),
_primaryShadowAnimation = CurvedAnimation( _primaryShadowAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
parent: primaryRouteAnimation, parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut, curve: Curves.linearToEaseOut,
)
).drive(_kGradientShadowTween), ).drive(_kGradientShadowTween),
super(key: key); super(key: key);
...@@ -618,7 +630,6 @@ class _CupertinoBackGestureController<T> { ...@@ -618,7 +630,6 @@ class _CupertinoBackGestureController<T> {
animateForward = velocity > 0 ? false : true; animateForward = velocity > 0 ? false : true;
else else
animateForward = controller.value > 0.5 ? true : false; animateForward = controller.value > 0.5 ? true : false;
if (animateForward) { if (animateForward) {
// The closer the panel is to dismissing, the shorter the animation is. // The closer the panel is to dismissing, the shorter the animation is.
// We want to cap the animation time, but we want to use a linear curve // We want to cap the animation time, but we want to use a linear curve
...@@ -650,9 +661,9 @@ class _CupertinoBackGestureController<T> { ...@@ -650,9 +661,9 @@ class _CupertinoBackGestureController<T> {
controller.removeStatusListener(_handleStatusChanged); controller.removeStatusListener(_handleStatusChanged);
} }
_animating = false; _animating = false;
onEnded();
if (status == AnimationStatus.dismissed) if (status == AnimationStatus.dismissed)
route.navigator.removeRoute(route); // this will cause the route to get disposed, which will dispose us route.navigator.removeRoute(route); // This also disposes the route.
onEnded(); // this will call dispose if popping the route failed to do so
} }
void dispose() { void dispose() {
......
...@@ -114,6 +114,12 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> { ...@@ -114,6 +114,12 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
AnimationController get controller => _controller; AnimationController get controller => _controller;
AnimationController _controller; AnimationController _controller;
/// 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 route pushed on top of this route.
Animation<double> get secondaryAnimation => _secondaryAnimation;
final ProxyAnimation _secondaryAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
/// Called to create the animation controller that will drive the transitions to /// Called to create the animation controller that will drive the transitions to
/// this route from the previous one, and back to the previous route from this /// this route from the previous one, and back to the previous route from this
/// one. /// one.
...@@ -164,12 +170,6 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> { ...@@ -164,12 +170,6 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
changedInternalState(); changedInternalState();
} }
/// 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.
Animation<double> get secondaryAnimation => _secondaryAnimation;
final ProxyAnimation _secondaryAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
@override @override
void install(OverlayEntry insertionPoint) { void install(OverlayEntry insertionPoint) {
assert(!_transitionCompleter.isCompleted, 'Cannot install a $runtimeType after disposing it.'); assert(!_transitionCompleter.isCompleted, 'Cannot install a $runtimeType after disposing it.');
......
...@@ -309,9 +309,8 @@ void main() { ...@@ -309,9 +309,8 @@ void main() {
expect(find.text('route'), findsNothing); expect(find.text('route'), findsNothing);
// Run the dismiss animation 75%, which exposes the route "push" button, // Run the dismiss animation 60%, which exposes the route "push" button,
// and then press the button. MaterialPageTransition duration is 300ms, // and then press the button.
// 275 = 300 * 0.75.
await tester.tap(find.text('push')); await tester.tap(find.text('push'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
...@@ -319,10 +318,27 @@ void main() { ...@@ -319,10 +318,27 @@ void main() {
expect(find.text('push'), findsNothing); expect(find.text('push'), findsNothing);
gesture = await tester.startGesture(const Offset(5, 300)); gesture = await tester.startGesture(const Offset(5, 300));
await gesture.moveBy(const Offset(400, 0)); // drag halfway await gesture.moveBy(const Offset(400, 0)); // Drag halfway.
await gesture.up(); await gesture.up();
await tester.pump(const Duration(milliseconds: 275)); // partially dismiss "route" // Trigger the snapping animation.
expect(find.text('route'), findsOneWidget); // Since the back swipe drag was brought to >=50% of the screen, it will
// self snap to finish the pop transition as the gesture is lifted.
//
// This drag drop animation is 400ms when dropped exactly halfway
// (800 / [pixel distance remaining], see
// _CupertinoBackGestureController.dragEnd). It follows a curve that is very
// steep initially.
await tester.pump();
expect(
tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(CupertinoPageScaffold))),
const Offset(400, 0),
);
// Let the dismissing snapping animation go 60%.
await tester.pump(const Duration(milliseconds: 240));
expect(
tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(CupertinoPageScaffold))).dx,
moreOrLessEquals(798, epsilon: 1),
);
await tester.tap(find.text('push')); await tester.tap(find.text('push'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('route'), findsOneWidget); expect(find.text('route'), findsOneWidget);
...@@ -431,4 +447,181 @@ void main() { ...@@ -431,4 +447,181 @@ void main() {
await tester.pump(const Duration(milliseconds: 40)); await tester.pump(const Duration(milliseconds: 40));
expect(tester.getTopLeft(find.byType(Placeholder)).dy, closeTo(600.0, 0.1)); expect(tester.getTopLeft(find.byType(Placeholder)).dy, closeTo(600.0, 0.1));
}); });
testWidgets('Animated push/pop is not linear', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Text('1'),
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Text('2'),
);
}
);
tester.state<NavigatorState>(find.byType(Navigator)).push(route2);
// The whole transition is 400ms based on CupertinoPageRoute.transitionDuration.
// Break it up into small chunks.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-87, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(537, epsilon: 1));
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-166, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(301, epsilon: 1));
await tester.pump(const Duration(milliseconds: 50));
// Translation slows down as time goes on.
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-220, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(141, epsilon: 1));
// Finish the rest of the animation
await tester.pump(const Duration(milliseconds: 250));
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-179, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(262, epsilon: 1));
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-100, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(499, epsilon: 1));
await tester.pump(const Duration(milliseconds: 50));
// Translation slows down as time goes on.
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-47, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(659, epsilon: 1));
});
testWidgets('Dragged pop gesture is linear', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Text('1'),
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Text('2'),
);
}
);
tester.state<NavigatorState>(find.byType(Navigator)).push(route2);
await tester.pumpAndSettle();
expect(find.text('1'), findsNothing);
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(0));
final TestGesture swipeGesture = await tester.startGesture(const Offset(5, 100));
await swipeGesture.moveBy(const Offset(100, 0));
await tester.pump();
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-233, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(100));
await swipeGesture.moveBy(const Offset(100, 0));
await tester.pump();
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-200));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(200));
// Moving by the same distance each time produces linear movements on both
// routes.
await swipeGesture.moveBy(const Offset(100, 0));
await tester.pump();
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-166, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(300));
});
testWidgets('Pop gesture snapping is not linear', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Text('1'),
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Text('2'),
);
}
);
tester.state<NavigatorState>(find.byType(Navigator)).push(route2);
await tester.pumpAndSettle();
final TestGesture swipeGesture = await tester.startGesture(const Offset(5, 100));
await swipeGesture.moveBy(const Offset(500, 0));
await swipeGesture.up();
await tester.pump();
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-100));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(500));
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-19, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(744, epsilon: 1));
await tester.pump(const Duration(milliseconds: 50));
// Rate of change is slowing down.
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-4, epsilon: 1));
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(787, epsilon: 1));
});
testWidgets('Snapped drags forwards and backwards should signal didStopUserGesture', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
navigatorKey: navigatorKey,
home: const Text('1'),
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Text('2'),
);
}
);
navigatorKey.currentState.push(route2);
await tester.pumpAndSettle();
await tester.dragFrom(const Offset(5, 100), const Offset(100, 0));
await tester.pump();
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(100));
expect(navigatorKey.currentState.userGestureInProgress, true);
// Didn't drag far enough to snap into dismissing this route.
// Each 100px distance takes 100ms to snap back.
await tester.pump(const Duration(milliseconds: 101));
// Back to the page covering the whole screen.
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(0));
expect(navigatorKey.currentState.userGestureInProgress, false);
await tester.dragFrom(const Offset(5, 100), const Offset(500, 0));
await tester.pump();
expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(500));
expect(navigatorKey.currentState.userGestureInProgress, true);
// Did go far enough to snap out of this route.
await tester.pump(const Duration(milliseconds: 301));
// Back to the page covering the whole screen.
expect(find.text('2'), findsNothing);
// First route covers the whole screen.
expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(0));
expect(navigatorKey.currentState.userGestureInProgress, false);
});
} }
...@@ -597,9 +597,9 @@ void main() { ...@@ -597,9 +597,9 @@ void main() {
expect(find.text('route'), findsNothing); expect(find.text('route'), findsNothing);
// Run the dismiss animation 75%, which exposes the route "push" button, // Run the dismiss animation 60%, which exposes the route "push" button,
// and then press the button. MaterialPageTransition duration is 300ms, // and then press the button. A drag dropped animation is 400ms when dropped
// 275 = 300 * 0.75. // exactly halfway. It follows a curve that is very steep initially.
await tester.tap(find.text('push')); await tester.tap(find.text('push'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
...@@ -607,10 +607,19 @@ void main() { ...@@ -607,10 +607,19 @@ void main() {
expect(find.text('push'), findsNothing); expect(find.text('push'), findsNothing);
gesture = await tester.startGesture(const Offset(5, 300)); gesture = await tester.startGesture(const Offset(5, 300));
await gesture.moveBy(const Offset(400, 0)); // drag halfway await gesture.moveBy(const Offset(400, 0)); // Drag halfway.
await gesture.up(); await gesture.up();
await tester.pump(const Duration(milliseconds: 275)); // partially dismiss "route" await tester.pump(); // Trigger the dropped snapping animation.
expect(find.text('route'), findsOneWidget); expect(
tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))),
const Offset(400, 0),
);
// Let the dismissing snapping animation go 60%.
await tester.pump(const Duration(milliseconds: 240));
expect(
tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))).dx,
moreOrLessEquals(798, epsilon: 1),
);
await tester.tap(find.text('push')); await tester.tap(find.text('push'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('route'), findsOneWidget); expect(find.text('route'), findsOneWidget);
......
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