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;