From 1fa925407654cd488c225bd0f17ff949308f0176 Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Thu, 20 Mar 2025 12:54:59 -0700 Subject: [PATCH] Cupertino navigation bars transitionBetweenRoutes fidelity update (#164956) In the before Flutter video, drag happens after the 17 second mark. ## Flutter before https://github.com/user-attachments/assets/9fbcd59e-68aa-4975-9a66-f05e72071223 ## Flutter after https://github.com/user-attachments/assets/784d7f46-a8ce-4e5f-a849-3b19df6668c9 ## Flutter back swipe drag gesture https://github.com/user-attachments/assets/09d38c01-aeea-46c1-90b4-590861f8f3a2 ## Native iOS https://github.com/user-attachments/assets/f2ab96c0-766b-4452-b6d5-3f92e6b6785f ## Flutter scaled back chevron and large title after https://github.com/user-attachments/assets/ba87add7-affa-4dcc-b2f0-abbc3487d677 ## Native iOS scaled back chevron and large title https://github.com/user-attachments/assets/5c7bfe5b-5789-4ab9-8e36-770cf802b1b1 Native iOS is probably using a spring simulation. This is the closest curve we have to the native transition, but should be updated in the future with the exact values. Fixes [Cupertino navigation bars transitionBetweenRoutes fidelity update](https://github.com/flutter/flutter/issues/164662) --- .../flutter/lib/src/cupertino/nav_bar.dart | 225 +++++++++++++++--- .../cupertino/nav_bar_transition_test.dart | 189 +++++++++------ 2 files changed, 302 insertions(+), 112 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 8226b28880..6147447d40 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -95,6 +95,18 @@ const Border _kTransparentNavBarBorder = Border( bottom: BorderSide(color: Color(0x00000000), width: 0.0), ); +/// The curve of the animation of the top nav bar regardless of push/pop +/// direction in the hero transition between two nav bars. +/// +/// Eyeballed on an iPhone 15 Pro simulator running iOS 17.5. +const Curve _kTopNavBarHeaderTransitionCurve = Cubic(0.0, 0.45, 0.45, 0.98); + +/// The curve of the animation of the bottom nav bar regardless of push/pop +/// direction in the hero transition between two nav bars. +/// +/// Eyeballed on an iPhone 15 Pro simulator running iOS 17.5. +const Curve _kBottomNavBarHeaderTransitionCurve = Cubic(0.05, 0.90, 0.90, 0.95); + // There's a single tag for all instances of navigation bars because they can // all transition between each other (per Navigator) via Hero transitions. const _HeroTag _defaultHeroTag = _HeroTag(null); @@ -2431,6 +2443,10 @@ class _TransitionableNavigationBar extends StatelessWidget { return box; } + bool get userGestureInProgress { + return Navigator.of(componentsKeys.navBarBoxKey.currentContext!).userGestureInProgress; + } + @override Widget build(BuildContext context) { assert(() { @@ -2485,20 +2501,13 @@ class _NavigationBarTransition extends StatelessWidget { }) : heightTween = Tween( begin: bottomNavBar.renderBox.size.height, end: topNavBar.renderBox.size.height, - ), - backgroundTween = ColorTween( - begin: bottomNavBar.backgroundColor, - end: topNavBar.backgroundColor, - ), - borderTween = BorderTween(begin: bottomNavBar.border, end: topNavBar.border); + ); final Animation animation; final _TransitionableNavigationBar topNavBar; final _TransitionableNavigationBar bottomNavBar; final Tween heightTween; - final ColorTween backgroundTween; - final BorderTween borderTween; @override Widget build(BuildContext context) { @@ -2511,21 +2520,8 @@ class _NavigationBarTransition extends StatelessWidget { ); final List children = [ - // Draw an empty navigation bar box with changing shape behind all the - // moving components without any components inside it itself. - AnimatedBuilder( - animation: animation, - builder: (BuildContext context, Widget? child) { - return _wrapWithBackground( - // Don't update the system status bar color mid-flight. - updateSystemUiOverlay: false, - backgroundColor: backgroundTween.evaluate(animation)!, - border: borderTween.evaluate(animation), - child: SizedBox(height: heightTween.evaluate(animation), width: double.infinity), - ); - }, - ), - // Draw all the components on top of the empty bar box. + if (componentsTransition.bottomNavBarBackground != null) + componentsTransition.bottomNavBarBackground!, if (componentsTransition.bottomBackChevron != null) componentsTransition.bottomBackChevron!, if (componentsTransition.bottomBackLabel != null) componentsTransition.bottomBackLabel!, if (componentsTransition.bottomLeading != null) componentsTransition.bottomLeading!, @@ -2534,6 +2530,8 @@ class _NavigationBarTransition extends StatelessWidget { if (componentsTransition.bottomTrailing != null) componentsTransition.bottomTrailing!, if (componentsTransition.bottomNavBarBottom != null) componentsTransition.bottomNavBarBottom!, // Draw top components on top of the bottom components. + if (componentsTransition.topNavBarBackground != null) + componentsTransition.topNavBarBackground!, if (componentsTransition.topLeading != null) componentsTransition.topLeading!, if (componentsTransition.topBackChevron != null) componentsTransition.topBackChevron!, if (componentsTransition.topBackLabel != null) componentsTransition.topBackLabel!, @@ -2600,6 +2598,12 @@ class _NavigationBarComponentsTransition { topHasUserMiddle = topNavBar.hasUserMiddle, bottomLargeExpanded = bottomNavBar.largeExpanded, topLargeExpanded = topNavBar.largeExpanded, + bottomBackgroundColor = bottomNavBar.backgroundColor, + topBackgroundColor = topNavBar.backgroundColor, + bottomBorder = bottomNavBar.border, + topBorder = topNavBar.border, + userGestureInProgress = + topNavBar.userGestureInProgress || bottomNavBar.userGestureInProgress, transitionBox = // paintBounds are based on offset zero so it's ok to expand the Rects. bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds), @@ -2629,6 +2633,12 @@ class _NavigationBarComponentsTransition { final bool topHasUserMiddle; final bool bottomLargeExpanded; final bool topLargeExpanded; + final bool userGestureInProgress; + + final Color? bottomBackgroundColor; + final Color? topBackgroundColor; + final Border? bottomBorder; + final Border? topBorder; // This is the outer box in which all the components will be fitted. The // sizing component of RelativeRects will be based on this rect's size. @@ -2637,7 +2647,7 @@ class _NavigationBarComponentsTransition { // 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 + // Take a widget in its original ancestor navigation bar render box and // translate it into a RelativeBox in the transition navigation bar box. RelativeRect positionInTransitionBox(GlobalKey key, {required RenderBox from}) { final RenderBox componentBox = key.currentContext!.findRenderObject()! as RenderBox; @@ -2667,6 +2677,7 @@ class _NavigationBarComponentsTransition { required RenderBox fromNavBarBox, required GlobalKey toKey, required RenderBox toNavBarBox, + Curve curve = const Interval(0.0, 1.0), required Widget child, }) { final RenderBox fromBox = fromKey.currentContext!.findRenderObject()! as RenderBox; @@ -2711,7 +2722,9 @@ class _NavigationBarComponentsTransition { return _FixedSizeSlidingTransition( isLTR: isLTR, - offsetAnimation: animation.drive(anchorMovementInTransitionBox), + offsetAnimation: animation + .drive(CurveTween(curve: curve)) + .drive(anchorMovementInTransitionBox), size: fromBox.size, child: child, ); @@ -2725,6 +2738,48 @@ class _NavigationBarComponentsTransition { return animation.drive(fadeOut.chain(CurveTween(curve: Interval(0.0, t, curve: curve)))); } + // The parent of the hero animation, which is the route animation. + Animation get routeAnimation { + // The hero animation is a CurvedAnimation. + assert(animation is CurvedAnimation); + return (animation as CurvedAnimation).parent; + } + + Widget? get bottomNavBarBackground { + if (bottomBackgroundColor == null) { + return null; + } + final Curve animationCurve = + animation.status == AnimationStatus.forward + ? Curves.fastEaseInToSlowEaseOut + : Curves.fastEaseInToSlowEaseOut.flipped; + + final Animation pageTransitionAnimation = routeAnimation.drive( + CurveTween(curve: userGestureInProgress ? Curves.linear : animationCurve), + ); + + final RelativeRect from = positionInTransitionBox( + bottomComponents.navBarBoxKey, + from: bottomNavBarBox, + ); + + final RelativeRectTween positionTween = RelativeRectTween( + end: from.shift(Offset(forwardDirection * -bottomNavBarBox.size.width, 0.0)), + begin: from, + ); + + return PositionedTransition( + rect: pageTransitionAnimation.drive(positionTween), + child: _wrapWithBackground( + // Don't update the system status bar color mid-flight. + updateSystemUiOverlay: false, + backgroundColor: bottomBackgroundColor!, + border: topBorder, + child: SizedBox(height: bottomNavBarBox.size.height, width: double.infinity), + ), + ); + } + Widget? get bottomLeading { final KeyedSubtree? bottomLeading = bottomComponents.leadingKey.currentWidget as KeyedSubtree?; @@ -2853,6 +2908,7 @@ class _NavigationBarComponentsTransition { fromNavBarBox: bottomNavBarBox, toKey: topComponents.backLabelKey, toNavBarBox: topNavBarBox, + curve: Interval(0.0, animation.status == AnimationStatus.forward ? 0.7 : 1.0), child: FadeTransition( opacity: fadeOutBy(0.6), child: Align( @@ -2931,20 +2987,64 @@ class _NavigationBarComponentsTransition { // Shift in from the leading edge of the screen. final RelativeRectTween positionTween = RelativeRectTween( begin: from, - end: from.shift(Offset(-forwardDirection * bottomNavBarBox.size.width, 0.0)), + end: from.shift(Offset(forwardDirection * -bottomNavBarBox.size.width, 0.0)), ); Widget child = bottomNavBarBottom.child; + final Curve animationCurve = + animation.status == AnimationStatus.forward + ? _kBottomNavBarHeaderTransitionCurve + : _kBottomNavBarHeaderTransitionCurve.flipped; // Fade out only if this is not a CupertinoSliverNavigationBar.search to // CupertinoSliverNavigationBar.search transition. if (topNavBarBottom == null || topNavBarBottom.child is! _InactiveSearchableBottom || bottomNavBarBottom.child is! _InactiveSearchableBottom) { - child = FadeTransition(opacity: fadeOutBy(0.8), child: child); + child = FadeTransition(opacity: fadeOutBy(0.8, curve: animationCurve), child: child); } - return PositionedTransition(rect: animation.drive(positionTween), child: child); + return PositionedTransition( + rect: + // The bottom widget animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), + + child: child, + ); + } + + Widget? get topNavBarBackground { + if (topBackgroundColor == null) { + return null; + } + final Curve animationCurve = + animation.status == AnimationStatus.forward + ? Curves.fastEaseInToSlowEaseOut + : Curves.fastEaseInToSlowEaseOut.flipped; + + final Animation pageTransitionAnimation = routeAnimation.drive( + CurveTween(curve: userGestureInProgress ? Curves.linear : animationCurve), + ); + + final RelativeRect to = positionInTransitionBox(topComponents.navBarBoxKey, from: topNavBarBox); + + final RelativeRectTween positionTween = RelativeRectTween( + begin: to.shift(Offset(forwardDirection * topNavBarBox.size.width, 0.0)), + end: to, + ); + + return PositionedTransition( + rect: pageTransitionAnimation.drive(positionTween), + child: _wrapWithBackground( + // Don't update the system status bar color mid-flight. + updateSystemUiOverlay: false, + backgroundColor: topBackgroundColor!, + border: topBorder, + child: SizedBox(height: topNavBarBox.size.height, width: double.infinity), + ), + ); } Widget? get topLeading { @@ -2976,21 +3076,50 @@ class _NavigationBarComponentsTransition { ); RelativeRect from = to; - // If it's the first page with a back chevron, shift in slightly from the - // right. + Widget child = topBackChevron.child; + // Values eyeballed from an iPhone 15 simulator running iOS 17.5. + const Curve forwardScaleCurve = Interval(0.0, 0.2); + const Curve backwardScaleCurve = Interval(0.8, 1.0); + const Curve forwardPositionCurve = Interval(0.0, 0.5); + const Curve backwardPositionCurve = Interval(0.5, 1.0); + final Curve effectiveScaleCurve; + final Curve effectivePositionCurve; + + if (animation.status == AnimationStatus.forward) { + effectiveScaleCurve = forwardScaleCurve; + effectivePositionCurve = forwardPositionCurve; + } else { + effectiveScaleCurve = backwardScaleCurve; + effectivePositionCurve = backwardPositionCurve; + } + + // If it's the first page with a back chevron, shrink and shift in slightly + // from the right. if (bottomBackChevron == null) { final RenderBox topBackChevronBox = topComponents.backChevronKey.currentContext!.findRenderObject()! as RenderBox; from = to.shift(Offset(forwardDirection * topBackChevronBox.size.width * 2.0, 0.0)); + child = ScaleTransition( + scale: routeAnimation.drive(CurveTween(curve: effectiveScaleCurve)), + child: child, + ); } final RelativeRectTween positionTween = RelativeRectTween(begin: from, end: to); return PositionedTransition( - rect: animation.drive(positionTween), + rect: routeAnimation.drive(CurveTween(curve: effectivePositionCurve)).drive(positionTween), child: FadeTransition( - opacity: fadeInFrom(bottomBackChevron == null ? 0.7 : 0.4), - child: DefaultTextStyle(style: topBackButtonTextStyle, child: topBackChevron.child), + opacity: routeAnimation.drive( + CurveTween( + curve: Interval( + // Fades faster going back from the first page with a back chevron. + bottomBackChevron == null && animation.status != AnimationStatus.forward ? 0.9 : 0.4, + 1.0, + ), + ), + ), + child: DefaultTextStyle(style: topBackButtonTextStyle, child: child), ), ); } @@ -3027,6 +3156,7 @@ class _NavigationBarComponentsTransition { fromNavBarBox: bottomNavBarBox, toKey: topComponents.backLabelKey, toNavBarBox: topNavBarBox, + curve: Interval(0.0, animation.status == AnimationStatus.forward ? 0.7 : 1.0), child: FadeTransition( opacity: midClickOpacity ?? fadeInFrom(0.4), child: DefaultTextStyleTransition( @@ -3140,10 +3270,19 @@ class _NavigationBarComponentsTransition { end: to, ); + final Curve animationCurve = + animation.status == AnimationStatus.forward + ? _kTopNavBarHeaderTransitionCurve + : _kTopNavBarHeaderTransitionCurve.flipped; + return PositionedTransition( - rect: animation.drive(positionTween), + rect: + // The large title animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), child: FadeTransition( - opacity: fadeInFrom(0.0), + opacity: fadeInFrom(0.0, curve: animationCurve), child: DefaultTextStyle( style: topLargeTitleTextStyle!, maxLines: 1, @@ -3176,15 +3315,27 @@ class _NavigationBarComponentsTransition { Widget child = topNavBarBottom.child; + final Curve animationCurve = + animation.status == AnimationStatus.forward + ? _kTopNavBarHeaderTransitionCurve + : _kTopNavBarHeaderTransitionCurve.flipped; + // Fade in only if this is not a CupertinoSliverNavigationBar.search to // CupertinoSliverNavigationBar.search transition. if (bottomNavBarBottom == null || bottomNavBarBottom.child is! _InactiveSearchableBottom || topNavBarBottom.child is! _InactiveSearchableBottom) { - child = FadeTransition(opacity: fadeInFrom(0.0), child: child); + child = FadeTransition(opacity: fadeInFrom(0.0, curve: animationCurve), child: child); } - return PositionedTransition(rect: animation.drive(positionTween), child: child); + return PositionedTransition( + rect: + // The bottom widget animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), + child: child, + ); } } diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart index a68b2d5109..2bbd9fc9f6 100644 --- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -107,20 +107,14 @@ Finder flying(WidgetTester tester, Finder finder) { return find.descendant(of: lastOverlayFinder, matching: finder); } -void checkBackgroundBoxHeight(WidgetTester tester, double height) { +void checkBackgroundBoxOffset(WidgetTester tester, int boxIndex, Offset offset) { final Widget transitionBackgroundBox = - tester.widget(flying(tester, find.byType(Stack))).children[0]; - expect( - tester - .widget( - find.descendant( - of: find.byWidget(transitionBackgroundBox), - matching: find.byType(SizedBox), - ), - ) - .height, - height, + tester.widget(flying(tester, find.byType(Stack))).children[boxIndex]; + final Offset testOffset = tester.getBottomRight( + find.descendant(of: find.byWidget(transitionBackgroundBox), matching: find.byType(SizedBox)), ); + expect(testOffset.dx, moreOrLessEquals(offset.dx, epsilon: 0.01)); + expect(testOffset.dy, moreOrLessEquals(offset.dy, epsilon: 0.01)); } void checkOpacity(WidgetTester tester, Finder finder, double opacity) { @@ -584,52 +578,57 @@ void main() { expect(find.text('Tab 1 Page 2', skipOffstage: false), findsNothing); }); - testWidgets('Transition box grows to large title size', (WidgetTester tester) async { + testWidgets('Bottom nav bar transition background box', (WidgetTester tester) async { await startTransitionBetween( tester, fromTitle: 'Page 1', - to: const CupertinoSliverNavigationBar(), + to: const CupertinoNavigationBar(), toTitle: 'Page 2', ); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 45.3376561999321); + // The top nav bar background box is the first component in the stack. + checkBackgroundBoxOffset(tester, 0, const Offset(609.14, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 51.012951374053955); + checkBackgroundBoxOffset(tester, 0, const Offset(362.91, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 63.06760931015015); + checkBackgroundBoxOffset(tester, 0, const Offset(192.14, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 75.89544230699539); + checkBackgroundBoxOffset(tester, 0, const Offset(95.30, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 84.33018499612808); + checkBackgroundBoxOffset(tester, 0, const Offset(46.12, 44.0)); }); - testWidgets('Large transition box shrinks to standard nav bar size', (WidgetTester tester) async { + testWidgets('Top nav bar transition background box', (WidgetTester tester) async { await startTransitionBetween( tester, - from: const CupertinoSliverNavigationBar(), + // Only the large title and background box are in the bottom nav bar. + from: const CupertinoNavigationBar(automaticallyImplyLeading: false), + to: const CupertinoNavigationBar(), fromTitle: 'Page 1', toTitle: 'Page 2', ); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 94.6623438000679); + // The component stack only contains the bottom box background (at index 0) + // and the large title (at index 1). + checkBackgroundBoxOffset(tester, 2, const Offset(1409.14, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 88.98704862594604); + checkBackgroundBoxOffset(tester, 2, const Offset(1162.91, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 76.93239068984985); + checkBackgroundBoxOffset(tester, 2, const Offset(992.14, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 64.10455769300461); + checkBackgroundBoxOffset(tester, 2, const Offset(895.30, 44.0)); await tester.pump(const Duration(milliseconds: 50)); - checkBackgroundBoxHeight(tester, 55.66981500387192); + checkBackgroundBoxOffset(tester, 2, const Offset(846.12, 44.0)); }); testWidgets('Hero flight removed at the end of page transition', (WidgetTester tester) async { @@ -756,11 +755,13 @@ void main() { ); // Come in from the right and fade in. checkOpacity(tester, backChevron, 0.0); - expect(tester.getTopLeft(backChevron), const Offset(87.2460581221158690823, 7.0)); + expect(tester.getTopLeft(backChevron).dx, moreOrLessEquals(80.54, epsilon: 0.01)); + expect(tester.getTopLeft(backChevron).dy, moreOrLessEquals(14.5, epsilon: 0.01)); await tester.pump(const Duration(milliseconds: 200)); - checkOpacity(tester, backChevron, 0.09497911669313908); - expect(tester.getTopLeft(backChevron), const Offset(30.8718595298545324113, 7.0)); + checkOpacity(tester, backChevron, 0.167); + expect(tester.getTopLeft(backChevron).dx, moreOrLessEquals(14.0, epsilon: 0.01)); + expect(tester.getTopLeft(backChevron).dy, moreOrLessEquals(7.0, epsilon: 0.01)); }); testWidgets('First appearance of back chevron fades in from the left in RTL', ( @@ -800,11 +801,13 @@ void main() { // Come in from the right and fade in. checkOpacity(tester, backChevron, 0.0); - expect(tester.getTopRight(backChevron), const Offset(687.163941725296126606, 7.0)); + expect(tester.getTopRight(backChevron).dx, moreOrLessEquals(706.66, epsilon: 0.01)); + expect(tester.getTopRight(backChevron).dy, moreOrLessEquals(14.5, epsilon: 0.01)); await tester.pump(const Duration(milliseconds: 200)); - checkOpacity(tester, backChevron, 0.09497911669313908); - expect(tester.getTopRight(backChevron), const Offset(743.538140317557690651, 7.0)); + checkOpacity(tester, backChevron, 0.167); + expect(tester.getTopRight(backChevron).dx, moreOrLessEquals(760.41, epsilon: 0.01)); + expect(tester.getTopRight(backChevron).dy, moreOrLessEquals(7.0, epsilon: 0.01)); }); testWidgets('Back chevron fades out and in when both pages have it', (WidgetTester tester) async { @@ -827,7 +830,7 @@ void main() { await tester.pump(const Duration(milliseconds: 200)); checkOpacity(tester, backChevrons.first, 0.0); - checkOpacity(tester, backChevrons.last, 0.4604858811944723); + checkOpacity(tester, backChevrons.last, 0.167); // Still in the same place. expect(tester.getTopLeft(backChevrons.first), const Offset(14.0, 7.0)); expect(tester.getTopLeft(backChevrons.last), const Offset(14.0, 7.0)); @@ -998,12 +1001,20 @@ void main() { checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0); expect( - tester.getTopLeft(flying(tester, find.text('Page 1')).first), - const Offset(16.9155227761479522997, 52.73951627314091), + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dx, + moreOrLessEquals(17.3, epsilon: 0.01), ); expect( - tester.getTopLeft(flying(tester, find.text('Page 1')).last), - const Offset(16.9155227761479522997, 52.73951627314091), + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dy, + moreOrLessEquals(52.2, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 200)); @@ -1011,12 +1022,20 @@ void main() { checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.4604858811944723); expect( - tester.getTopLeft(flying(tester, find.text('Page 1')).first), - const Offset(43.6029094262710827934, 22.49655644595623), + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dx, + moreOrLessEquals(51.6, epsilon: 0.01), ); expect( - tester.getTopLeft(flying(tester, find.text('Page 1')).last), - const Offset(43.6029094262710827934, 22.49655644595623), + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dy, + moreOrLessEquals(11.5, epsilon: 0.01), ); }); @@ -1038,21 +1057,21 @@ void main() { expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); expect(flying(tester, find.byType(Placeholder)), findsOneWidget); - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.946); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.777); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(-20.58, epsilon: 0.01), + moreOrLessEquals(-156.62, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 200)); // Halfway through the transition, the bottom is only slightly visible. - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.011); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(-620.46, epsilon: 0.01), + moreOrLessEquals(-751.94, epsilon: 0.01), ); }); @@ -1074,21 +1093,21 @@ void main() { expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); expect(flying(tester, find.byType(Placeholder)), findsOneWidget); - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.946); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.777); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(-20.58, epsilon: 0.01), + moreOrLessEquals(-156.62, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 200)); // Halfway through the transition, the bottom is only slightly visible. - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.011); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(-620.46, epsilon: 0.01), + moreOrLessEquals(-751.94, epsilon: 0.01), ); }); @@ -1109,24 +1128,40 @@ void main() { checkOpacity(tester, flying(tester, find.text('A title too long to fit')), 0.9280824661254883); checkOpacity(tester, flying(tester, find.text('Back')), 0.0); expect( - tester.getTopLeft(flying(tester, find.text('A title too long to fit'))), - const Offset(16.9155227761479522997, 52.73951627314091), + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dx, + moreOrLessEquals(17.3, epsilon: 0.01), ); expect( - tester.getTopLeft(flying(tester, find.text('Back'))), - const Offset(16.9155227761479522997, 52.73951627314091), + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dy, + moreOrLessEquals(52.2, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 200)); checkOpacity(tester, flying(tester, find.text('A title too long to fit')), 0.0); checkOpacity(tester, flying(tester, find.text('Back')), 0.4604858811944723); expect( - tester.getTopLeft(flying(tester, find.text('A title too long to fit'))), - const Offset(43.6029094262710827934, 22.49655644595623), + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dx, + moreOrLessEquals(51.6, epsilon: 0.01), ); expect( - tester.getTopLeft(flying(tester, find.text('Back'))), - const Offset(43.6029094262710827934, 22.49655644595623), + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dy, + moreOrLessEquals(11.5, epsilon: 0.01), ); }); @@ -1251,19 +1286,21 @@ void main() { expect(flying(tester, find.text('Page 2')), findsOneWidget); - checkOpacity(tester, flying(tester, find.text('Page 2')), 0.001); + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.193); expect( - tester.getTopLeft(flying(tester, find.text('Page 2'))), - const Offset(795.4206738471985, 54.0), + tester.getTopLeft(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(661.64, epsilon: 0.01), ); + expect(tester.getTopLeft(flying(tester, find.text('Page 2'))).dy, 54.0); await tester.pump(const Duration(milliseconds: 150)); - checkOpacity(tester, flying(tester, find.text('Page 2')), 0.444); + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.899); expect( - tester.getTopLeft(flying(tester, find.text('Page 2'))), - const Offset(325.3008875846863, 54.0), + tester.getTopLeft(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(96.57, epsilon: 0.01), ); + expect(tester.getTopLeft(flying(tester, find.text('Page 2'))).dy, 54.0); }); testWidgets('Top large title fades in and slides in from the left in RTL', ( @@ -1280,19 +1317,21 @@ void main() { expect(flying(tester, find.text('Page 2')), findsOneWidget); - checkOpacity(tester, flying(tester, find.text('Page 2')), 0.001); + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.193); expect( - tester.getTopRight(flying(tester, find.text('Page 2'))), - const Offset(4.579326152801514, 54.0), + tester.getTopRight(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(138.36, epsilon: 0.01), ); + expect(tester.getTopRight(flying(tester, find.text('Page 2'))).dy, 54.0); await tester.pump(const Duration(milliseconds: 150)); - checkOpacity(tester, flying(tester, find.text('Page 2')), 0.444); + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.899); expect( - tester.getTopRight(flying(tester, find.text('Page 2'))), - const Offset(474.6991124153137, 54.0), + tester.getTopRight(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(703.43, epsilon: 0.01), ); + expect(tester.getTopRight(flying(tester, find.text('Page 2'))).dy, 54.0); }); testWidgets('Top CupertinoSliverNavigationBar.bottom is aligned with top large title animation', ( @@ -1331,12 +1370,12 @@ void main() { // The nav bar bottom is horizontally aligned to the large title. expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - largeTitleOffset.dx - horizontalPadding, + moreOrLessEquals(largeTitleOffset.dx - horizontalPadding, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 150)); - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.444); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.899); largeTitleOffset = tester.getTopLeft(flying(tester, find.text('Page 2'))); @@ -1363,20 +1402,20 @@ void main() { expect(flying(tester, find.text('Page 2')), findsOneWidget); expect(flying(tester, find.byType(Placeholder)), findsOneWidget); - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.193); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(779.42, epsilon: 0.01), + moreOrLessEquals(645.64, epsilon: 0.01), ); await tester.pump(const Duration(milliseconds: 150)); - checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.444); + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.899); expect( tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, - moreOrLessEquals(309.30, epsilon: 0.01), + moreOrLessEquals(80.57, epsilon: 0.01), ); });