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

diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart
index eba6a78..ad4993a 100644
--- a/packages/flutter/lib/src/cupertino/route.dart
+++ b/packages/flutter/lib/src/cupertino/route.dart
@@ -180,22 +180,19 @@
     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 @@
     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 @@
   // 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 @@
       onEnded: () {
         backController?.dispose();
         backController = null;
-        _popGestureInProgress.remove(route);
       },
     );
     return backController;
@@ -313,9 +312,12 @@
       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 @@
     @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 @@
       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 @@
       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() {
diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart
index eb23e00..798d960 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -114,6 +114,12 @@
   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 @@
     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.');
diff --git a/packages/flutter/test/cupertino/route_test.dart b/packages/flutter/test/cupertino/route_test.dart
index dd15284..4250fcd 100644
--- a/packages/flutter/test/cupertino/route_test.dart
+++ b/packages/flutter/test/cupertino/route_test.dart
@@ -309,9 +309,8 @@
     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 @@
     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 @@
     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);
+  });
 }
diff --git a/packages/flutter/test/material/page_test.dart b/packages/flutter/test/material/page_test.dart
index fd0ff40..ebdce13 100644
--- a/packages/flutter/test/material/page_test.dart
+++ b/packages/flutter/test/material/page_test.dart
@@ -597,9 +597,9 @@
     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 @@
     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);