From 87ca3d52a9d12dea9b6dd91e6efadec4b10536e3 Mon Sep 17 00:00:00 2001 From: xster Date: Fri, 26 Oct 2018 12:11:50 -0700 Subject: [PATCH] Back swipe hero (#23320) --- .../flutter/lib/src/cupertino/nav_bar.dart | 6 + packages/flutter/lib/src/cupertino/route.dart | 2 +- packages/flutter/lib/src/widgets/heroes.dart | 129 ++++++++++++------ .../flutter/lib/src/widgets/navigator.dart | 24 +++- .../cupertino/nav_bar_transition_test.dart | 106 ++++++++++++++ .../flutter/test/widgets/heroes_test.dart | 110 ++++++++++++++- .../flutter/test/widgets/navigator_test.dart | 39 ++++++ 7 files changed, 361 insertions(+), 55 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 4deb7078fb..804fa14437 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -316,6 +316,10 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer /// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] /// with [transitionBetweenRoutes] set to true. /// + /// This transition will also occur on edge back swipe gestures like on iOS + /// but only if the previous page below has `maintainState` set to true on the + /// [PageRoute]. + /// /// When set to true, only one navigation bar can be present per route unless /// [heroTag] is also set. /// @@ -398,6 +402,7 @@ class _CupertinoNavigationBarState extends State { createRectTween: _linearTranslateWithLargestRectSizeTween, placeholderBuilder: _navBarHeroLaunchPadBuilder, flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, + transitionOnUserGestures: true, child: _TransitionableNavigationBar( componentsKeys: keys, backgroundColor: widget.backgroundColor, @@ -732,6 +737,7 @@ class _LargeTitleNavigationBarSliverDelegate createRectTween: _linearTranslateWithLargestRectSizeTween, flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, placeholderBuilder: _navBarHeroLaunchPadBuilder, + transitionOnUserGestures: true, // This is all the way down here instead of being at the top level of // CupertinoSliverNavigationBar like CupertinoNavigationBar because it // needs to wrap the top level RenderBox rather than a RenderSliver. diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 957def25f5..5eccb7ba6f 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -253,7 +253,7 @@ class CupertinoPageRoute extends PageRoute { } // Called by _CupertinoBackGestureDetector when a pop ("back") drag start - // gesture is detected. The returned controller handles all of the subsquent + // gesture is detected. The returned controller handles all of the subsequent // drag events. static _CupertinoBackGestureController _startPopGesture(PageRoute route) { assert(!_popGestureInProgress.contains(route)); diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index fe55d7ac32..c9d03dfc9c 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -123,8 +123,10 @@ class Hero extends StatefulWidget { this.createRectTween, this.flightShuttleBuilder, this.placeholderBuilder, + this.transitionOnUserGestures = false, @required this.child, }) : assert(tag != null), + assert(transitionOnUserGestures != null), assert(child != null), super(key: key); @@ -176,31 +178,49 @@ class Hero extends StatefulWidget { /// left in place once the Hero shuttle has taken flight. final TransitionBuilder placeholderBuilder; + /// Whether to perform the hero transition if the [PageRoute] transition was + /// triggered by a user gesture, such as a back swipe on iOS. + /// + /// If [Hero]s with the same [tag] on both the from and the to routes have + /// [transitionOnUserGestures] set to true, a back swipe gesture will + /// trigger the same hero animation as a programmatically triggered push or + /// pop. + /// + /// The route being popped to or the bottom route must also have + /// [PageRoute.maintainState] set to true for a gesture triggered hero + /// transition to work. + /// + /// Defaults to false and cannot be null. + final bool transitionOnUserGestures; + // Returns a map of all of the heroes in context, indexed by hero tag. - static Map _allHeroesFor(BuildContext context) { + static Map _allHeroesFor(BuildContext context, bool isUserGestureTransition) { assert(context != null); + assert(isUserGestureTransition != null); final Map result = {}; void visitor(Element element) { if (element.widget is Hero) { final StatefulElement hero = element; final Hero heroWidget = element.widget; - final Object tag = heroWidget.tag; - assert(tag != null); - assert(() { - if (result.containsKey(tag)) { - throw FlutterError( - 'There are multiple heroes that share the same tag within a subtree.\n' - 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), ' - 'each Hero must have a unique non-null tag.\n' - 'In this case, multiple heroes had the following tag: $tag\n' - 'Here is the subtree for one of the offending heroes:\n' - '${element.toStringDeep(prefixLineOne: "# ")}' - ); - } - return true; - }()); - final _HeroState heroState = hero.state; - result[tag] = heroState; + if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) { + final Object tag = heroWidget.tag; + assert(tag != null); + assert(() { + if (result.containsKey(tag)) { + throw FlutterError( + 'There are multiple heroes that share the same tag within a subtree.\n' + 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), ' + 'each Hero must have a unique non-null tag.\n' + 'In this case, multiple heroes had the following tag: $tag\n' + 'Here is the subtree for one of the offending heroes:\n' + '${element.toStringDeep(prefixLineOne: "# ")}' + ); + } + return true; + }()); + final _HeroState heroState = hero.state; + result[tag] = heroState; + } } // Don't perform transitions across different Navigators. if (element.widget is Navigator) { @@ -274,6 +294,7 @@ class _HeroFlightManifest { @required this.toHero, @required this.createRectTween, @required this.shuttleBuilder, + @required this.isUserGestureTransition, }) : assert(fromHero.widget.tag == toHero.widget.tag); final HeroFlightDirection type; @@ -285,6 +306,7 @@ class _HeroFlightManifest { final _HeroState toHero; final CreateRectTween createRectTween; final HeroFlightShuttleBuilder shuttleBuilder; + final bool isUserGestureTransition; Object get tag => fromHero.widget.tag; @@ -410,7 +432,12 @@ class _HeroFlight { assert(type != null); switch (type) { case HeroFlightDirection.pop: - return initial.value == 1.0 && initial.status == AnimationStatus.reverse; + return initial.value == 1.0 && initialManifest.isUserGestureTransition + // During user gesture transitions, the animation controller isn't + // driving the reverse transition, but should still be in a previously + // completed stage with the initial value at 1.0. + ? initial.status == AnimationStatus.completed + : initial.status == AnimationStatus.reverse; case HeroFlightDirection.push: return initial.value == 0.0 && initial.status == AnimationStatus.forward; } @@ -532,14 +559,11 @@ class HeroController extends NavigatorObserver { /// linear [Tween]. HeroController({ this.createRectTween }); - /// Used to create [RectTween]s that interpolate the position of heros in flight. + /// Used to create [RectTween]s that interpolate the position of heroes in flight. /// /// If null, the controller uses a linear [RectTween]. final CreateRectTween createRectTween; - // Disable Hero animations while a user gesture is controlling the navigation. - bool _questsEnabled = true; - // All of the heroes that are currently in the overlay and in motion. // Indexed by the hero tag. final Map _flights = {}; @@ -548,56 +572,70 @@ class HeroController extends NavigatorObserver { void didPush(Route route, Route previousRoute) { assert(navigator != null); assert(route != null); - _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push); + _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push, false); } @override void didPop(Route route, Route previousRoute) { assert(navigator != null); assert(route != null); - _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop); + _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, false); } @override - void didStartUserGesture() { - _questsEnabled = false; - } - - @override - void didStopUserGesture() { - _questsEnabled = true; + void didStartUserGesture(Route route, Route previousRoute) { + assert(navigator != null); + assert(route != null); + _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, true); } // If we're transitioning between different page routes, start a hero transition // after the toRoute has been laid out with its animation's value at 1.0. - void _maybeStartHeroTransition(Route fromRoute, Route toRoute, HeroFlightDirection flightType) { - if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute && fromRoute is PageRoute) { + void _maybeStartHeroTransition( + Route fromRoute, + Route toRoute, + HeroFlightDirection flightType, + bool isUserGestureTransition, + ) { + if (toRoute != fromRoute && toRoute is PageRoute && fromRoute is PageRoute) { final PageRoute from = fromRoute; final PageRoute to = toRoute; final Animation animation = (flightType == HeroFlightDirection.push) ? to.animation : from.animation; // A user gesture may have already completed the pop. - if (flightType == HeroFlightDirection.pop && animation.status == AnimationStatus.dismissed) + if (flightType == HeroFlightDirection.pop && animation.status == AnimationStatus.dismissed) { return; + } - // Putting a route offstage changes its animation value to 1.0. Once this - // frame completes, we'll know where the heroes in the `to` route are - // going to end up, and the `to` route will go back onstage. - to.offstage = to.animation.value == 0.0; + // For pop transitions driven by a user gesture: if the "to" page has + // maintainState = true, then the hero's final dimensions can be measured + // immediately because their page's layout is still valid. + if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) { + _startHeroTransition(from, to, animation, flightType, isUserGestureTransition); + } else { + // Otherwise, delay measuring until the end of the next frame to allow + // the 'to' route to build and layout. - WidgetsBinding.instance.addPostFrameCallback((Duration value) { - _startHeroTransition(from, to, animation, flightType); - }); + // Putting a route offstage changes its animation value to 1.0. Once this + // frame completes, we'll know where the heroes in the `to` route are + // going to end up, and the `to` route will go back onstage. + to.offstage = to.animation.value == 0.0; + + WidgetsBinding.instance.addPostFrameCallback((Duration value) { + _startHeroTransition(from, to, animation, flightType, isUserGestureTransition); + }); + } } } - // Find the matching pairs of heros in from and to and either start or a new + // Find the matching pairs of heroes in from and to and either start or a new // hero flight, or divert an existing one. void _startHeroTransition( PageRoute from, PageRoute to, Animation animation, HeroFlightDirection flightType, + bool isUserGestureTransition, ) { // If the navigator or one of the routes subtrees was removed before this // end-of-frame callback was called, then don't actually start a transition. @@ -609,8 +647,8 @@ class HeroController extends NavigatorObserver { final Rect navigatorRect = _globalBoundingBoxFor(navigator.context); // At this point the toHeroes may have been built and laid out for the first time. - final Map fromHeroes = Hero._allHeroesFor(from.subtreeContext); - final Map toHeroes = Hero._allHeroesFor(to.subtreeContext); + final Map fromHeroes = Hero._allHeroesFor(from.subtreeContext, isUserGestureTransition); + final Map toHeroes = Hero._allHeroesFor(to.subtreeContext, isUserGestureTransition); // If the `to` route was offstage, then we're implicitly restoring its // animation value back to what it was before it was "moved" offstage. @@ -632,6 +670,7 @@ class HeroController extends NavigatorObserver { createRectTween: createRectTween, shuttleBuilder: toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder, + isUserGestureTransition: isUserGestureTransition, ); if (_flights[tag] != null) diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index f3c932410b..6ce4e1fe73 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -348,11 +348,18 @@ class NavigatorObserver { /// The [Navigator] replaced `oldRoute` with `newRoute`. void didReplace({ Route newRoute, Route oldRoute }) { } - /// The [Navigator]'s routes are being moved by a user gesture. + /// The [Navigator]'s route `route` is being moved by a user gesture. /// - /// For example, this is called when an iOS back gesture starts, and is used - /// to disabled hero animations during such interactions. - void didStartUserGesture() { } + /// For example, this is called when an iOS back gesture starts. + /// + /// Paired with a call to [didStopUserGesture] when the route is no longer + /// being manipulated via user gesture. + /// + /// If present, the route immediately below `route` is `previousRoute`. + /// Though the gesture may not necessarily conclude at `previousRoute` if + /// the gesture is canceled. In that case, [didStopUserGesture] is still + /// called but a follow-up [didPop] is not. + void didStartUserGesture(Route route, Route previousRoute) { } /// User gesture is no longer controlling the [Navigator]. /// @@ -1911,8 +1918,15 @@ class NavigatorState extends State with TickerProviderStateMixin { void didStartUserGesture() { _userGesturesInProgress += 1; if (_userGesturesInProgress == 1) { + final Route route = _history.last; + final Route previousRoute = !route.willHandlePopInternally && _history.length > 1 + ? _history[_history.length - 2] + : null; + // Don't operate the _history list since the gesture may be cancelled. + // In case of a back swipe, the gesture controller will call .pop() itself. + for (NavigatorObserver observer in widget.observers) - observer.didStartUserGesture(); + observer.didStartUserGesture(route, previousRoute); } } diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart index 048087fda8..ea3d146e74 100644 --- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -1015,4 +1015,110 @@ void main() { expect(bottomBuildTimes, 2); expect(topBuildTimes, 3); }); + + testWidgets('Back swipe gesture transitions', + (WidgetTester tester) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + // Go to the next page. + await tester.pump(const Duration(milliseconds: 500)); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(100.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + + // Page 2, which is the middle of the top route, start to fly back to the right. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(352.5802058875561, 13.5), + ); + + // Page 1 is in transition in 2 places. Once as the top back label and once + // as the bottom middle. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + + // Past the halfway point now. + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + + await tester.pump(); + + // Transition continues. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(654.2055835723877, 13.5), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(720.8727767467499, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // Cleans up properly + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + // Just the bottom route's middle now. + expect(find.text('Page 1'), findsOneWidget); + }); + + testWidgets('Back swipe gesture cancels properly with transition', + (WidgetTester tester) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + // Go to the next page. + await tester.pump(const Duration(milliseconds: 500)); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(100.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + + // Page 2, which is the middle of the top route, start to fly back to the right. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(352.5802058875561, 13.5), + ); + + await gesture.up(); + await tester.pump(); + + // Transition continues from the point we let off. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(352.5802058875561, 13.5), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(350.00985169410706, 13.5), + ); + + // Finish the snap back animation. + await tester.pump(const Duration(milliseconds: 500)); + + // Cleans up properly + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + // Back to page 2. + expect(find.text('Page 2'), findsOneWidget); + }); } diff --git a/packages/flutter/test/widgets/heroes_test.dart b/packages/flutter/test/widgets/heroes_test.dart index 45fd966c84..4cdb8fba16 100644 --- a/packages/flutter/test/widgets/heroes_test.dart +++ b/packages/flutter/test/widgets/heroes_test.dart @@ -14,13 +14,19 @@ Key homeRouteKey = const Key('homeRoute'); Key routeTwoKey = const Key('routeTwo'); Key routeThreeKey = const Key('routeThree'); +bool transitionFromUserGestures = false; + final Map routes = { '/': (BuildContext context) => Material( child: ListView( key: homeRouteKey, children: [ Container(height: 100.0, width: 100.0), - Card(child: Hero(tag: 'a', child: Container(height: 100.0, width: 100.0, key: firstKey))), + Card(child: Hero( + tag: 'a', + transitionOnUserGestures: transitionFromUserGestures, + child: Container(height: 100.0, width: 100.0, key: firstKey), + )), Container(height: 100.0, width: 100.0), FlatButton( child: const Text('two'), @@ -42,7 +48,11 @@ final Map routes = { onPressed: () { Navigator.pop(context); } ), Container(height: 150.0, width: 150.0), - Card(child: Hero(tag: 'a', child: Container(height: 150.0, width: 150.0, key: secondKey))), + Card(child: Hero( + tag: 'a', + transitionOnUserGestures: transitionFromUserGestures, + child: Container(height: 150.0, width: 150.0, key: secondKey), + )), Container(height: 150.0, width: 150.0), FlatButton( child: const Text('three'), @@ -67,7 +77,11 @@ final Map routes = { Card( child: Padding( padding: const EdgeInsets.only(left: 50.0), - child: Hero(tag: 'a', child: Container(height: 150.0, width: 150.0, key: secondKey)) + child: Hero( + tag: 'a', + transitionOnUserGestures: transitionFromUserGestures, + child: Container(height: 150.0, width: 150.0, key: secondKey), + ) ), ), Container(height: 150.0, width: 150.0), @@ -78,7 +92,6 @@ final Map routes = { ] ) ), - }; class ThreeRoute extends MaterialPageRoute { @@ -121,6 +134,10 @@ class MyStatefulWidgetState extends State { } void main() { + setUp(() { + transitionFromUserGestures = false; + }); + testWidgets('Heroes animate', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(routes: routes)); @@ -1335,4 +1352,89 @@ void main() { expect(find.text('Venom'), findsOneWidget); expect(find.text('Joker'), findsOneWidget); }); + + testWidgets('Heroes do not transition on back gestures by default', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + platform: TargetPlatform.iOS, + ), + routes: routes, + )); + + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isInCard); + expect(find.byKey(secondKey), findsNothing); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byKey(firstKey), findsNothing); + expect(find.byKey(secondKey), isOnstage); + expect(find.byKey(secondKey), isInCard); + + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + await gesture.moveBy(const Offset(200.0, 0.0)); + + await tester.pump(); + + // Both Heros exist and seated in their normal parents. + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isInCard); + expect(find.byKey(secondKey), isOnstage); + expect(find.byKey(secondKey), isInCard); + + // To make sure the hero had all chances of starting. + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isInCard); + expect(find.byKey(secondKey), isOnstage); + expect(find.byKey(secondKey), isInCard); + }); + + testWidgets('Heroes can transition on gesture in one frame', (WidgetTester tester) async { + transitionFromUserGestures = true; + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + platform: TargetPlatform.iOS, + ), + routes: routes, + )); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byKey(firstKey), findsNothing); + expect(find.byKey(secondKey), isOnstage); + expect(find.byKey(secondKey), isInCard); + + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + await gesture.moveBy(const Offset(200.0, 0.0)); + await tester.pump(); + + // We're going to page 1 so page 1's Hero is lifted into flight. + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isNotInCard); + expect(find.byKey(secondKey), findsNothing); + + // Move further along. + await gesture.moveBy(const Offset(500.0, 0.0)); + await tester.pump(); + + // Same results. + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isNotInCard); + expect(find.byKey(secondKey), findsNothing); + + await gesture.up(); + // Finish transition. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Hero A is back in the card. + expect(find.byKey(firstKey), isOnstage); + expect(find.byKey(firstKey), isInCard); + expect(find.byKey(secondKey), findsNothing); + }); } diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 8c9577f3ca..70f0d61659 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -96,6 +96,7 @@ class TestObserver extends NavigatorObserver { OnObservation onPopped; OnObservation onRemoved; OnObservation onReplaced; + OnObservation onStartUserGesture; @override void didPush(Route route, Route previousRoute) { @@ -122,6 +123,12 @@ class TestObserver extends NavigatorObserver { if (onReplaced != null) onReplaced(newRoute, oldRoute); } + + @override + void didStartUserGesture(Route route, Route previousRoute) { + if (onStartUserGesture != null) + onStartUserGesture(route, previousRoute); + } } void main() { @@ -715,6 +722,38 @@ void main() { expect(log, ['pushed / (previous is )', 'pushed B (previous is /)', 'pushed C (previous is B)', 'replaced B with D']); }); + testWidgets('didStartUserGesture observable', + (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), + '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }), + }; + + Route observedRoute; + Route observedPreviousRoute; + final TestObserver observer = TestObserver() + ..onStartUserGesture = (Route route, Route previousRoute) { + observedRoute = route; + observedPreviousRoute = previousRoute; + }; + + await tester.pumpWidget(MaterialApp( + routes: routes, + navigatorObservers: [observer], + )); + + await tester.tap(find.text('/')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('/'), findsNothing); + expect(find.text('A'), findsOneWidget); + + tester.state(find.byType(Navigator)).didStartUserGesture(); + + expect(observedRoute.settings.name, '/A'); + expect(observedPreviousRoute.settings.name, '/'); + }); + testWidgets('ModalRoute.of sets up a route to rebuild if its state changes', (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final List log = [];