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> {
return nextRoute is CupertinoPageRoute && !nextRoute.fullscreenDialog;
}
@override
void dispose() {
_popGestureInProgress.remove(this);
super.dispose();
}
/// True if a Cupertino pop gesture is currently underway for [route].
/// True if an iOS-style back swipe pop gesture is currently underway for [route].
///
/// This just check the route's [NavigatorState.userGestureInProgress].
///
/// See also:
///
/// * [popGestureEnabled], which returns true if a user-triggered pop gesture
/// would be allowed.
static bool isPopGestureInProgress(PageRoute<dynamic> route) => _popGestureInProgress.contains(route);
static final Set<PageRoute<dynamic>> _popGestureInProgress = <PageRoute<dynamic>>{};
static bool isPopGestureInProgress(PageRoute<dynamic> route) {
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:
///
......@@ -233,10 +230,15 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
if (route.fullscreenDialog)
return false;
// 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;
// If we're in a gesture already, we cannot start another.
if (_popGestureInProgress.contains(route))
if (isPopGestureInProgress(route))
return false;
// Looks like a back gesture would be welcome!
......@@ -266,9 +268,7 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
// gesture is detected. The returned controller handles all of the subsequent
// drag events.
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
assert(!_popGestureInProgress.contains(route));
assert(_isPopGestureEnabled(route));
_popGestureInProgress.add(route);
_CupertinoBackGestureController<T> backController;
backController = _CupertinoBackGestureController<T>(
......@@ -277,7 +277,6 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
onEnded: () {
backController?.dispose();
backController = null;
_popGestureInProgress.remove(route);
},
);
return backController;
......@@ -313,9 +312,12 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
return CupertinoPageTransition(
primaryRouteAnimation: animation,
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
// match finger motions.
linearTransition: _popGestureInProgress.contains(route),
linearTransition: isPopGestureInProgress(route),
child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route),
......@@ -354,28 +356,38 @@ class CupertinoPageTransition extends StatelessWidget {
@required this.child,
@required bool linearTransition,
}) : assert(linearTransition != null),
_primaryPositionAnimation = (linearTransition ? primaryRouteAnimation :
// The curves below have been rigorously derived from plots of native
// iOS animation frames. Specifically, a video was taken of a 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
// point set, which is linearToEaseIn. Conversely, easeInToLinear is the
// reflection over the origin of linearToEaseIn.
CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
)
).drive(_kRightMiddleTween),
_secondaryPositionAnimation = CurvedAnimation(
parent: secondaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
).drive(_kMiddleLeftTween),
_primaryShadowAnimation = CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
).drive(_kGradientShadowTween),
_primaryPositionAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
// The curves below have been rigorously derived from plots of native
// iOS animation frames. Specifically, a video was taken of a 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
// point set, which is linearToEaseIn. Conversely, easeInToLinear is the
// reflection over the origin of linearToEaseIn.
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
)
).drive(_kRightMiddleTween),
_secondaryPositionAnimation =
(linearTransition
? secondaryRouteAnimation
: CurvedAnimation(
parent: secondaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
)
).drive(_kMiddleLeftTween),
_primaryShadowAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
)
).drive(_kGradientShadowTween),
super(key: key);
// When this page is coming in to cover another page.
......@@ -618,7 +630,6 @@ class _CupertinoBackGestureController<T> {
animateForward = velocity > 0 ? false : true;
else
animateForward = controller.value > 0.5 ? true : false;
if (animateForward) {
// 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
......@@ -650,9 +661,9 @@ class _CupertinoBackGestureController<T> {
controller.removeStatusListener(_handleStatusChanged);
}
_animating = false;
onEnded();
if (status == AnimationStatus.dismissed)
route.navigator.removeRoute(route); // 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
route.navigator.removeRoute(route); // This also disposes the route.
}
void dispose() {
......
......@@ -114,6 +114,12 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
AnimationController get controller => _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
/// this route from the previous one, and back to the previous route from this
/// one.
......@@ -164,12 +170,6 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
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
void install(OverlayEntry insertionPoint) {
assert(!_transitionCompleter.isCompleted, 'Cannot install a $runtimeType after disposing it.');
......
......@@ -309,9 +309,8 @@ void main() {
expect(find.text('route'), findsNothing);
// Run the dismiss animation 75%, which exposes the route "push" button,
// and then press the button. MaterialPageTransition duration is 300ms,
// 275 = 300 * 0.75.
// Run the dismiss animation 60%, which exposes the route "push" button,
// and then press the button.
await tester.tap(find.text('push'));
await tester.pumpAndSettle();
......@@ -319,10 +318,27 @@ void main() {
expect(find.text('push'), findsNothing);
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 tester.pump(const Duration(milliseconds: 275)); // partially dismiss "route"
expect(find.text('route'), findsOneWidget);
// Trigger the snapping animation.
// 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.pumpAndSettle();
expect(find.text('route'), findsOneWidget);
......@@ -431,4 +447,181 @@ void main() {
await tester.pump(const Duration(milliseconds: 40));
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() {
expect(find.text('route'), findsNothing);
// Run the dismiss animation 75%, which exposes the route "push" button,
// and then press the button. MaterialPageTransition duration is 300ms,
// 275 = 300 * 0.75.
// Run the dismiss animation 60%, which exposes the route "push" button,
// and then press the button. A drag dropped animation is 400ms when dropped
// exactly halfway. It follows a curve that is very steep initially.
await tester.tap(find.text('push'));
await tester.pumpAndSettle();
......@@ -607,10 +607,19 @@ void main() {
expect(find.text('push'), findsNothing);
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 tester.pump(const Duration(milliseconds: 275)); // partially dismiss "route"
expect(find.text('route'), findsOneWidget);
await tester.pump(); // Trigger the dropped snapping animation.
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.pumpAndSettle();
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