diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 6590ee058d..4deb7078fb 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -1410,8 +1410,8 @@ class _TransitionableNavigationBar extends StatelessWidget { class _NavigationBarTransition extends StatelessWidget { _NavigationBarTransition({ @required this.animation, - @required _TransitionableNavigationBar topNavBar, - @required _TransitionableNavigationBar bottomNavBar, + @required this.topNavBar, + @required this.bottomNavBar, }) : heightTween = Tween( begin: bottomNavBar.renderBox.size.height, end: topNavBar.renderBox.size.height, @@ -1423,15 +1423,11 @@ class _NavigationBarTransition extends StatelessWidget { borderTween = BorderTween( begin: bottomNavBar.border, end: topNavBar.border, - ), - componentsTransition = _NavigationBarComponentsTransition( - animation: animation, - bottomNavBar: bottomNavBar, - topNavBar: topNavBar, ); final Animation animation; - final _NavigationBarComponentsTransition componentsTransition; + final _TransitionableNavigationBar topNavBar; + final _TransitionableNavigationBar bottomNavBar; final Tween heightTween; final ColorTween backgroundTween; @@ -1439,6 +1435,13 @@ class _NavigationBarTransition extends StatelessWidget { @override Widget build(BuildContext context) { + final _NavigationBarComponentsTransition componentsTransition = _NavigationBarComponentsTransition( + animation: animation, + bottomNavBar: bottomNavBar, + topNavBar: topNavBar, + directionality: Directionality.of(context), + ); + final List children = [ // Draw an empty navigation bar box with changing shape behind all the // moving components without any components inside it itself. @@ -1516,6 +1519,7 @@ class _NavigationBarComponentsTransition { @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 @@ class _NavigationBarComponentsTransition { 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 fadeOut = Tween( begin: 1.0, @@ -1560,6 +1565,9 @@ class _NavigationBarComponentsTransition { // 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 @@ class _NavigationBarComponentsTransition { // 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 @@ class _NavigationBarComponentsTransition { 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 @@ class _NavigationBarComponentsTransition { - 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 @@ class _NavigationBarComponentsTransition { 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 @@ class _NavigationBarComponentsTransition { } 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 @@ class _NavigationBarComponentsTransition { ); } - // 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 @@ class _NavigationBarComponentsTransition { } 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 @@ class _NavigationBarComponentsTransition { } 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 @@ class _NavigationBarComponentsTransition { // 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 @@ class _NavigationBarComponentsTransition { // 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 @@ class _NavigationBarComponentsTransition { // 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 a4b2e8a688..048087fda8 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 @@ Future startTransitionBetween( 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 @@ void main() { ); }); + 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 @@ void main() { 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(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 @@ void main() { 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(find.byType(Navigator)) + .push(CupertinoPageRoute( + 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 @@ void main() { // 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { ); }); + 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(find.byType(Navigator)) + .push(CupertinoPageRoute( + 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 @@ void main() { // 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 @@ void main() { 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 @@ void main() { ); 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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;