Support RTL Cupertino nav bar transitions between routes (#23221)

diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart
index 6590ee0..4deb707 100644
--- a/packages/flutter/lib/src/cupertino/nav_bar.dart
+++ b/packages/flutter/lib/src/cupertino/nav_bar.dart
@@ -1410,8 +1410,8 @@
 class _NavigationBarTransition extends StatelessWidget {
   _NavigationBarTransition({
     @required this.animation,
-    @required _TransitionableNavigationBar topNavBar,
-    @required _TransitionableNavigationBar bottomNavBar,
+    @required this.topNavBar,
+    @required this.bottomNavBar,
   }) : heightTween = Tween<double>(
          begin: bottomNavBar.renderBox.size.height,
          end: topNavBar.renderBox.size.height,
@@ -1423,15 +1423,11 @@
        borderTween = BorderTween(
          begin: bottomNavBar.border,
          end: topNavBar.border,
-       ),
-       componentsTransition = _NavigationBarComponentsTransition(
-         animation: animation,
-         bottomNavBar: bottomNavBar,
-         topNavBar: topNavBar,
        );
 
   final Animation<double> animation;
-  final _NavigationBarComponentsTransition componentsTransition;
+  final _TransitionableNavigationBar topNavBar;
+  final _TransitionableNavigationBar bottomNavBar;
 
   final Tween<double> heightTween;
   final ColorTween backgroundTween;
@@ -1439,6 +1435,13 @@
 
   @override
   Widget build(BuildContext context) {
+    final _NavigationBarComponentsTransition componentsTransition = _NavigationBarComponentsTransition(
+      animation: animation,
+      bottomNavBar: bottomNavBar,
+      topNavBar: topNavBar,
+      directionality: Directionality.of(context),
+    );
+
     final List<Widget> children = <Widget>[
       // Draw an empty navigation bar box with changing shape behind all the
       // moving components without any components inside it itself.
@@ -1516,6 +1519,7 @@
     @required this.animation,
     @required _TransitionableNavigationBar bottomNavBar,
     @required _TransitionableNavigationBar topNavBar,
+    @required TextDirection directionality,
   }) : bottomComponents = bottomNavBar.componentsKeys,
        topComponents = topNavBar.componentsKeys,
        bottomNavBarBox = bottomNavBar.renderBox,
@@ -1528,7 +1532,8 @@
        topLargeExpanded = topNavBar.largeExpanded,
        transitionBox =
            // paintBounds are based on offset zero so it's ok to expand the Rects.
-           bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds);
+           bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds),
+       forwardDirection = directionality == TextDirection.ltr ? 1.0 : -1.0;
 
   static final Animatable<double> fadeOut = Tween<double>(
     begin: 1.0,
@@ -1560,6 +1565,9 @@
   // sizing component of RelativeRects will be based on this rect's size.
   final Rect transitionBox;
 
+  // x-axis unity number representing the direction of growth for text.
+  final double forwardDirection;
+
   // Take a widget it its original ancestor navigation bar render box and
   // translate it into a RelativeBox in the transition navigation bar box.
   RelativeRect positionInTransitionBox(
@@ -1579,8 +1587,8 @@
   // ancestor navigation bar to another widget's position in that widget's
   // navigation bar.
   //
-  // Anchor their positions based on the center of their respective render
-  // boxes' leading edge.
+  // Anchor their positions based on the vertical middle of their respective
+  // render boxes' leading edge.
   //
   // Also produce RelativeRects with sizes that would preserve the constant
   // BoxConstraints of the 'from' widget so that animating font sizes etc don't
@@ -1595,7 +1603,11 @@
 
     final RenderBox fromBox = fromKey.currentContext.findRenderObject();
     final RenderBox toBox = toKey.currentContext.findRenderObject();
-    final Rect toRect =
+
+    // We move a box with the size of the 'from' render object such that its
+    // upper left corner is at the upper left corner of the 'to' render object.
+    // With slight y axis adjustment for those render objects' height differences.
+    Rect toRect =
         toBox.localToGlobal(
           Offset.zero,
           ancestor: toNavBarBox,
@@ -1604,6 +1616,12 @@
           - fromBox.size.height / 2 + toBox.size.height / 2
         ) & fromBox.size; // Keep the from render object's size.
 
+    if (forwardDirection < 0) {
+      // If RTL, move the center right to the center right instead of matching
+      // the center lefts.
+      toRect = toRect.translate(- fromBox.size.width + toBox.size.width, 0.0);
+    }
+
     return RelativeRectTween(
         begin: fromRect,
         end: RelativeRect.fromRect(toRect, transitionBox),
@@ -1666,10 +1684,15 @@
 
     final RelativeRect from = positionInTransitionBox(bottomComponents.backLabelKey, from: bottomNavBarBox);
 
-    // Transition away by sliding horizontally to the left off of the screen.
+    // Transition away by sliding horizontally to the leading edge off of the screen.
     final RelativeRectTween positionTween = RelativeRectTween(
       begin: from,
-      end: from.shift(Offset(-bottomNavBarBox.size.width / 2.0, 0.0)),
+      end: from.shift(
+        Offset(
+          forwardDirection * (-bottomNavBarBox.size.width / 2.0),
+          0.0,
+        ),
+      ),
     );
 
     return PositionedTransition(
@@ -1696,6 +1719,7 @@
     }
 
     if (bottomMiddle != null && topBackLabel != null) {
+      // Move from current position to the top page's back label position.
       return PositionedTransition(
         rect: animation.drive(slideFromLeadingEdge(
           fromKey: bottomComponents.middleKey,
@@ -1722,8 +1746,9 @@
       );
     }
 
-    // When the top page has a leading widget override, don't move the bottom
-    // middle widget.
+    // When the top page has a leading widget override (one of the few ways to
+    // not have a top back label), don't move the bottom middle widget and just
+    // fade.
     if (bottomMiddle != null && topLeading != null) {
       return Positioned.fromRelativeRect(
         rect: positionInTransitionBox(bottomComponents.middleKey, from: bottomNavBarBox),
@@ -1751,6 +1776,7 @@
     }
 
     if (bottomLargeTitle != null && topBackLabel != null) {
+      // Move from current position to the top page's back label position.
       return PositionedTransition(
         rect: animation.drive(slideFromLeadingEdge(
           fromKey: bottomComponents.largeTitleKey,
@@ -1779,15 +1805,22 @@
     }
 
     if (bottomLargeTitle != null && topLeading != null) {
+      // Unlike bottom middle, the bottom large title moves when it can't
+      // transition to the top back label position.
       final RelativeRect from = positionInTransitionBox(bottomComponents.largeTitleKey, from: bottomNavBarBox);
 
       final RelativeRectTween positionTween = RelativeRectTween(
         begin: from,
-        end: from.shift(Offset(bottomNavBarBox.size.width / 4.0, 0.0)),
+        end: from.shift(
+          Offset(
+            forwardDirection * bottomNavBarBox.size.width / 4.0,
+            0.0,
+          ),
+        ),
       );
 
-      // Just shift slightly towards the right instead of moving to the back
-      // label position.
+      // Just shift slightly towards the trailing edge instead of moving to the
+      // back label position.
       return PositionedTransition(
         rect: animation.drive(positionTween),
         child: FadeTransition(
@@ -1851,7 +1884,12 @@
     // right.
     if (bottomBackChevron == null) {
       final RenderBox topBackChevronBox = topComponents.backChevronKey.currentContext.findRenderObject();
-      from = to.shift(Offset(topBackChevronBox.size.width * 2.0, 0.0));
+      from = to.shift(
+        Offset(
+          forwardDirection * topBackChevronBox.size.width * 2.0,
+          0.0,
+        ),
+      );
     }
 
     final RelativeRectTween positionTween = RelativeRectTween(
@@ -1967,7 +2005,12 @@
 
     // Shift in from the trailing edge of the screen.
     final RelativeRectTween positionTween = RelativeRectTween(
-      begin: to.shift(Offset(topNavBarBox.size.width / 2.0, 0.0)),
+      begin: to.shift(
+        Offset(
+          forwardDirection * topNavBarBox.size.width / 2.0,
+          0.0,
+        ),
+      ),
       end: to,
     );
 
@@ -2010,7 +2053,12 @@
 
     // Shift in from the trailing edge of the screen.
     final RelativeRectTween positionTween = RelativeRectTween(
-      begin: to.shift(Offset(topNavBarBox.size.width, 0.0)),
+      begin: to.shift(
+        Offset(
+          forwardDirection * topNavBarBox.size.width,
+          0.0,
+        ),
+      ),
       end: to,
     );
 
diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart
index a4b2e8a..048087f 100644
--- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart
+++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart
@@ -12,10 +12,17 @@
   Widget to,
   String fromTitle,
   String toTitle,
+  TextDirection textDirection = TextDirection.ltr,
 }) async {
   await tester.pumpWidget(
-    const CupertinoApp(
-      home: Placeholder(),
+    CupertinoApp(
+      builder: (BuildContext context, Widget navigator) {
+        return Directionality(
+          textDirection: textDirection,
+          child: navigator,
+        );
+      },
+      home: const Placeholder(),
     ),
   );
 
@@ -145,6 +152,29 @@
     );
   });
 
+  testWidgets('Bottom middle moves between middle and back label RTL',
+      (WidgetTester tester) async {
+    await startTransitionBetween(
+      tester,
+      fromTitle: 'Page 1',
+      textDirection: TextDirection.rtl,
+    );
+
+    await tester.pump(const Duration(milliseconds: 50));
+
+    expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
+
+    // Same as LTR but more to the right now.
+    expect(
+      tester.getTopLeft(flying(tester, find.text('Page 1')).first),
+      const Offset(366.9275064468384, 13.5),
+    );
+    expect(
+      tester.getTopLeft(flying(tester, find.text('Page 1')).last),
+      const Offset(366.9275064468384, 13.5),
+    );
+  });
+
   testWidgets('Bottom middle and top back label transitions their font',
       (WidgetTester tester) async {
     await startTransitionBetween(tester, fromTitle: 'Page 1');
@@ -293,6 +323,52 @@
     checkColorAndPositionAt50ms();
   });
 
+  testWidgets('Popping mid-transition is symmetrical RTL',
+      (WidgetTester tester) async {
+    await startTransitionBetween(
+      tester,
+      fromTitle: 'Page 1',
+      textDirection: TextDirection.rtl,
+    );
+
+    // Be mid-transition.
+    await tester.pump(const Duration(milliseconds: 50));
+
+    void checkColorAndPositionAt50ms() {
+      // The transition's stack is ordered. The bottom middle is inserted first.
+      final RenderParagraph bottomMiddle =
+          tester.renderObject(flying(tester, find.text('Page 1')).first);
+      expect(bottomMiddle.text.style.color, const Color(0xFF00070F));
+      expect(
+        tester.getTopLeft(flying(tester, find.text('Page 1')).first),
+        const Offset(366.9275064468384, 13.5),
+      );
+
+      // The top back label is styled exactly the same way. But the opacity tweens
+      // are flipped.
+      final RenderParagraph topBackLabel =
+          tester.renderObject(flying(tester, find.text('Page 1')).last);
+      expect(topBackLabel.text.style.color, const Color(0xFF00070F));
+      expect(
+        tester.getTopLeft(flying(tester, find.text('Page 1')).last),
+        const Offset(366.9275064468384, 13.5),
+      );
+    }
+
+    checkColorAndPositionAt50ms();
+
+    // Advance more.
+    await tester.pump(const Duration(milliseconds: 100));
+
+    // Pop and reverse the same amount of time.
+    tester.state<NavigatorState>(find.byType(Navigator)).pop();
+    await tester.pump();
+    await tester.pump(const Duration(milliseconds: 100));
+
+    // Check that everything's the same as on the way in.
+    checkColorAndPositionAt50ms();
+  });
+
   testWidgets('There should be no global keys in the hero flight',
       (WidgetTester tester) async {
     await startTransitionBetween(tester, fromTitle: 'Page 1');
@@ -430,6 +506,54 @@
         tester.getTopLeft(backChevron), const Offset(18.033634185791016, 5.0));
   });
 
+  testWidgets('First appearance of back chevron fades in from the left in RTL',
+      (WidgetTester tester) async {
+    await tester.pumpWidget(
+      CupertinoApp(
+        builder: (BuildContext context, Widget navigator) {
+          return Directionality(
+            textDirection: TextDirection.rtl,
+            child: navigator,
+          );
+        },
+        home: scaffoldForNavBar(null),
+      ),
+    );
+
+    tester
+        .state<NavigatorState>(find.byType(Navigator))
+        .push(CupertinoPageRoute<void>(
+          title: 'Page 1',
+          builder: (BuildContext context) => scaffoldForNavBar(null),
+        ));
+
+    await tester.pump();
+    await tester.pump(const Duration(milliseconds: 50));
+
+    final Finder backChevron = flying(tester,
+        find.text(String.fromCharCode(CupertinoIcons.back.codePoint)));
+
+    expect(
+      backChevron,
+      // Only one exists from the top page. The bottom page has no back chevron.
+      findsOneWidget,
+    );
+
+    // Come in from the right and fade in.
+    checkOpacity(tester, backChevron, 0.0);
+    expect(
+      tester.getTopRight(backChevron),
+      const Offset(694.0500679016113, 5.0),
+    );
+
+    await tester.pump(const Duration(milliseconds: 150));
+    checkOpacity(tester, backChevron, 0.32467134296894073);
+    expect(
+      tester.getTopRight(backChevron),
+      const Offset(747.966365814209, 5.0),
+    );
+  });
+
   testWidgets('Back chevron fades out and in when both pages have it',
       (WidgetTester tester) async {
     await startTransitionBetween(tester, fromTitle: 'Page 1');
@@ -474,8 +598,7 @@
     // There's just 1 in flight because there's no back label on the top page.
     expect(flying(tester, find.text('Page 1')), findsOneWidget);
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 1')), 0.8609542846679688);
+    checkOpacity(tester, flying(tester, find.text('Page 1')), 0.8609542846679688);
 
     // The middle widget doesn't move.
     expect(
@@ -503,8 +626,7 @@
 
     expect(flying(tester, find.text('custom')), findsOneWidget);
 
-    checkOpacity(
-        tester, flying(tester, find.text('custom')), 0.7655444294214249);
+    checkOpacity(tester, flying(tester, find.text('custom')), 0.7655444294214249);
     expect(
       tester.getTopLeft(flying(tester, find.text('custom'))),
       const Offset(16.0, 0.0),
@@ -530,8 +652,7 @@
 
     expect(flying(tester, find.text('custom')), findsOneWidget);
 
-    checkOpacity(
-        tester, flying(tester, find.text('custom')), 0.8393326997756958);
+    checkOpacity(tester, flying(tester, find.text('custom')), 0.8393326997756958);
     expect(
       tester.getTopLeft(flying(tester, find.text('custom'))),
       const Offset(683.0, 13.5),
@@ -568,8 +689,7 @@
     expect(flying(tester, find.text('Page 1')), findsOneWidget);
 
     // Back label fades out faster.
-    checkOpacity(
-        tester, flying(tester, find.text('Page 1')), 0.5584745407104492);
+    checkOpacity(tester, flying(tester, find.text('Page 1')), 0.5584745407104492);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 1'))),
       const Offset(24.176071166992188, 13.5),
@@ -583,6 +703,45 @@
     );
   });
 
+  testWidgets('Bottom back label fades and slides to the right in RTL',
+      (WidgetTester tester) async {
+    await startTransitionBetween(
+      tester,
+      fromTitle: 'Page 1',
+      toTitle: 'Page 2',
+      textDirection: TextDirection.rtl,
+    );
+
+    await tester.pump(const Duration(milliseconds: 500));
+    tester
+        .state<NavigatorState>(find.byType(Navigator))
+        .push(CupertinoPageRoute<void>(
+          title: 'Page 3',
+          builder: (BuildContext context) => scaffoldForNavBar(null),
+        ));
+
+    await tester.pump();
+    await tester.pump(const Duration(milliseconds: 50));
+
+    // 'Page 1' appears once on Page 2 as the back label.
+    expect(flying(tester, find.text('Page 1')), findsOneWidget);
+
+    // Back label fades out faster.
+    checkOpacity(tester, flying(tester, find.text('Page 1')), 0.5584745407104492);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 1'))),
+      const Offset(775.8239288330078, 13.5),
+    );
+
+    await tester.pump(const Duration(milliseconds: 150));
+    checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 1'))),
+      // >1000. It's now off the screen.
+      const Offset(1092.9786224365234, 13.5),
+    );
+  });
+
   testWidgets('Bottom large title moves to top back label',
       (WidgetTester tester) async {
     await startTransitionBetween(
@@ -598,8 +757,7 @@
     // bottom back label fading in.
     expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 1')).first, 0.8393326997756958);
+    checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.8393326997756958);
     checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 1')).first),
@@ -612,8 +770,7 @@
 
     await tester.pump(const Duration(milliseconds: 150));
     checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0);
-    checkOpacity(
-        tester, flying(tester, find.text('Page 1')).last, 0.6276369094848633);
+    checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.6276369094848633);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 1')).first),
       const Offset(43.278289794921875, 19.23011875152588),
@@ -653,8 +810,7 @@
     );
 
     await tester.pump(const Duration(milliseconds: 150));
-    checkOpacity(
-        tester, flying(tester, find.text('A title too long to fit')), 0.0);
+    checkOpacity(tester, flying(tester, find.text('A title too long to fit')), 0.0);
     checkOpacity(tester, flying(tester, find.text('Back')), 0.6276369094848633);
     expect(
       tester.getTopLeft(flying(tester, find.text('A title too long to fit'))),
@@ -717,8 +873,7 @@
 
     expect(flying(tester, find.text('Page 2')), findsOneWidget);
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 2')), 0.0);
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 2'))),
       const Offset(725.1760711669922, 13.5),
@@ -726,14 +881,40 @@
 
     await tester.pump(const Duration(milliseconds: 150));
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 2')), 0.6972532719373703);
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.6972532719373703);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 2'))),
       const Offset(408.02137756347656, 13.5),
     );
   });
 
+  testWidgets('Top middle fades in and slides in from the left in RTL',
+      (WidgetTester tester) async {
+    await startTransitionBetween(
+      tester,
+      toTitle: 'Page 2',
+      textDirection: TextDirection.rtl,
+    );
+
+    await tester.pump(const Duration(milliseconds: 50));
+
+    expect(flying(tester, find.text('Page 2')), findsOneWidget);
+
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 2'))),
+      const Offset(74.82392883300781, 13.5),
+    );
+
+    await tester.pump(const Duration(milliseconds: 150));
+
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.6972532719373703);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 2'))),
+      const Offset(391.97862243652344, 13.5),
+    );
+  });
+
   testWidgets('Top large title fades in and slides in from the right',
       (WidgetTester tester) async {
     await startTransitionBetween(
@@ -746,8 +927,7 @@
 
     expect(flying(tester, find.text('Page 2')), findsOneWidget);
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 2')), 0.0);
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 2'))),
       const Offset(768.3521423339844, 54.0),
@@ -755,14 +935,41 @@
 
     await tester.pump(const Duration(milliseconds: 150));
 
-    checkOpacity(
-        tester, flying(tester, find.text('Page 2')), 0.6753286570310593);
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.6753286570310593);
     expect(
       tester.getTopLeft(flying(tester, find.text('Page 2'))),
       const Offset(134.04275512695312, 54.0),
     );
   });
 
+  testWidgets('Top large title fades in and slides in from the left in RTL',
+      (WidgetTester tester) async {
+    await startTransitionBetween(
+      tester,
+      to: const CupertinoSliverNavigationBar(),
+      toTitle: 'Page 2',
+      textDirection: TextDirection.rtl,
+    );
+
+    await tester.pump(const Duration(milliseconds: 50));
+
+    expect(flying(tester, find.text('Page 2')), findsOneWidget);
+
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 2'))),
+      const Offset(31.647857666015625, 54.0),
+    );
+
+    await tester.pump(const Duration(milliseconds: 150));
+
+    checkOpacity(tester, flying(tester, find.text('Page 2')), 0.6753286570310593);
+    expect(
+      tester.getTopRight(flying(tester, find.text('Page 2'))),
+      const Offset(665.9572448730469, 54.0),
+    );
+  });
+
   testWidgets('Components are not unnecessarily rebuilt during transitions',
           (WidgetTester tester) async {
     int bottomBuildTimes = 0;