Unverified Commit 87ca3d52 authored by xster's avatar xster Committed by GitHub

Back swipe hero (#23320)

parent c7b10a2d
...@@ -316,6 +316,10 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer ...@@ -316,6 +316,10 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer
/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] /// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar]
/// with [transitionBetweenRoutes] set to true. /// 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 /// When set to true, only one navigation bar can be present per route unless
/// [heroTag] is also set. /// [heroTag] is also set.
/// ///
...@@ -398,6 +402,7 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> { ...@@ -398,6 +402,7 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
createRectTween: _linearTranslateWithLargestRectSizeTween, createRectTween: _linearTranslateWithLargestRectSizeTween,
placeholderBuilder: _navBarHeroLaunchPadBuilder, placeholderBuilder: _navBarHeroLaunchPadBuilder,
flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, flightShuttleBuilder: _navBarHeroFlightShuttleBuilder,
transitionOnUserGestures: true,
child: _TransitionableNavigationBar( child: _TransitionableNavigationBar(
componentsKeys: keys, componentsKeys: keys,
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
...@@ -732,6 +737,7 @@ class _LargeTitleNavigationBarSliverDelegate ...@@ -732,6 +737,7 @@ class _LargeTitleNavigationBarSliverDelegate
createRectTween: _linearTranslateWithLargestRectSizeTween, createRectTween: _linearTranslateWithLargestRectSizeTween,
flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, flightShuttleBuilder: _navBarHeroFlightShuttleBuilder,
placeholderBuilder: _navBarHeroLaunchPadBuilder, placeholderBuilder: _navBarHeroLaunchPadBuilder,
transitionOnUserGestures: true,
// This is all the way down here instead of being at the top level of // This is all the way down here instead of being at the top level of
// CupertinoSliverNavigationBar like CupertinoNavigationBar because it // CupertinoSliverNavigationBar like CupertinoNavigationBar because it
// needs to wrap the top level RenderBox rather than a RenderSliver. // needs to wrap the top level RenderBox rather than a RenderSliver.
......
...@@ -253,7 +253,7 @@ class CupertinoPageRoute<T> extends PageRoute<T> { ...@@ -253,7 +253,7 @@ class CupertinoPageRoute<T> extends PageRoute<T> {
} }
// Called by _CupertinoBackGestureDetector when a pop ("back") drag start // 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. // drag events.
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) { static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
assert(!_popGestureInProgress.contains(route)); assert(!_popGestureInProgress.contains(route));
......
...@@ -123,8 +123,10 @@ class Hero extends StatefulWidget { ...@@ -123,8 +123,10 @@ class Hero extends StatefulWidget {
this.createRectTween, this.createRectTween,
this.flightShuttleBuilder, this.flightShuttleBuilder,
this.placeholderBuilder, this.placeholderBuilder,
this.transitionOnUserGestures = false,
@required this.child, @required this.child,
}) : assert(tag != null), }) : assert(tag != null),
assert(transitionOnUserGestures != null),
assert(child != null), assert(child != null),
super(key: key); super(key: key);
...@@ -176,31 +178,49 @@ class Hero extends StatefulWidget { ...@@ -176,31 +178,49 @@ class Hero extends StatefulWidget {
/// left in place once the Hero shuttle has taken flight. /// left in place once the Hero shuttle has taken flight.
final TransitionBuilder placeholderBuilder; 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. // Returns a map of all of the heroes in context, indexed by hero tag.
static Map<Object, _HeroState> _allHeroesFor(BuildContext context) { static Map<Object, _HeroState> _allHeroesFor(BuildContext context, bool isUserGestureTransition) {
assert(context != null); assert(context != null);
assert(isUserGestureTransition != null);
final Map<Object, _HeroState> result = <Object, _HeroState>{}; final Map<Object, _HeroState> result = <Object, _HeroState>{};
void visitor(Element element) { void visitor(Element element) {
if (element.widget is Hero) { if (element.widget is Hero) {
final StatefulElement hero = element; final StatefulElement hero = element;
final Hero heroWidget = element.widget; final Hero heroWidget = element.widget;
final Object tag = heroWidget.tag; if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) {
assert(tag != null); final Object tag = heroWidget.tag;
assert(() { assert(tag != null);
if (result.containsKey(tag)) { assert(() {
throw FlutterError( if (result.containsKey(tag)) {
'There are multiple heroes that share the same tag within a subtree.\n' throw FlutterError(
'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), ' 'There are multiple heroes that share the same tag within a subtree.\n'
'each Hero must have a unique non-null tag.\n' 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), '
'In this case, multiple heroes had the following tag: $tag\n' 'each Hero must have a unique non-null tag.\n'
'Here is the subtree for one of the offending heroes:\n' 'In this case, multiple heroes had the following tag: $tag\n'
'${element.toStringDeep(prefixLineOne: "# ")}' 'Here is the subtree for one of the offending heroes:\n'
); '${element.toStringDeep(prefixLineOne: "# ")}'
} );
return true; }
}()); return true;
final _HeroState heroState = hero.state; }());
result[tag] = heroState; final _HeroState heroState = hero.state;
result[tag] = heroState;
}
} }
// Don't perform transitions across different Navigators. // Don't perform transitions across different Navigators.
if (element.widget is Navigator) { if (element.widget is Navigator) {
...@@ -274,6 +294,7 @@ class _HeroFlightManifest { ...@@ -274,6 +294,7 @@ class _HeroFlightManifest {
@required this.toHero, @required this.toHero,
@required this.createRectTween, @required this.createRectTween,
@required this.shuttleBuilder, @required this.shuttleBuilder,
@required this.isUserGestureTransition,
}) : assert(fromHero.widget.tag == toHero.widget.tag); }) : assert(fromHero.widget.tag == toHero.widget.tag);
final HeroFlightDirection type; final HeroFlightDirection type;
...@@ -285,6 +306,7 @@ class _HeroFlightManifest { ...@@ -285,6 +306,7 @@ class _HeroFlightManifest {
final _HeroState toHero; final _HeroState toHero;
final CreateRectTween createRectTween; final CreateRectTween createRectTween;
final HeroFlightShuttleBuilder shuttleBuilder; final HeroFlightShuttleBuilder shuttleBuilder;
final bool isUserGestureTransition;
Object get tag => fromHero.widget.tag; Object get tag => fromHero.widget.tag;
...@@ -410,7 +432,12 @@ class _HeroFlight { ...@@ -410,7 +432,12 @@ class _HeroFlight {
assert(type != null); assert(type != null);
switch (type) { switch (type) {
case HeroFlightDirection.pop: 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: case HeroFlightDirection.push:
return initial.value == 0.0 && initial.status == AnimationStatus.forward; return initial.value == 0.0 && initial.status == AnimationStatus.forward;
} }
...@@ -532,14 +559,11 @@ class HeroController extends NavigatorObserver { ...@@ -532,14 +559,11 @@ class HeroController extends NavigatorObserver {
/// linear [Tween<Rect>]. /// linear [Tween<Rect>].
HeroController({ this.createRectTween }); 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]. /// If null, the controller uses a linear [RectTween].
final CreateRectTween createRectTween; 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. // All of the heroes that are currently in the overlay and in motion.
// Indexed by the hero tag. // Indexed by the hero tag.
final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{}; final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
...@@ -548,56 +572,70 @@ class HeroController extends NavigatorObserver { ...@@ -548,56 +572,70 @@ class HeroController extends NavigatorObserver {
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(route != null);
_maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push); _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push, false);
} }
@override @override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) { void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(route != null);
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop); _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, false);
}
@override
void didStartUserGesture() {
_questsEnabled = false;
} }
@override @override
void didStopUserGesture() { void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
_questsEnabled = true; assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, true);
} }
// If we're transitioning between different page routes, start a hero transition // 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. // after the toRoute has been laid out with its animation's value at 1.0.
void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, HeroFlightDirection flightType) { void _maybeStartHeroTransition(
if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) { Route<dynamic> fromRoute,
Route<dynamic> toRoute,
HeroFlightDirection flightType,
bool isUserGestureTransition,
) {
if (toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
final PageRoute<dynamic> from = fromRoute; final PageRoute<dynamic> from = fromRoute;
final PageRoute<dynamic> to = toRoute; final PageRoute<dynamic> to = toRoute;
final Animation<double> animation = (flightType == HeroFlightDirection.push) ? to.animation : from.animation; final Animation<double> animation = (flightType == HeroFlightDirection.push) ? to.animation : from.animation;
// A user gesture may have already completed the pop. // 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; return;
}
// Putting a route offstage changes its animation value to 1.0. Once this // For pop transitions driven by a user gesture: if the "to" page has
// frame completes, we'll know where the heroes in the `to` route are // maintainState = true, then the hero's final dimensions can be measured
// going to end up, and the `to` route will go back onstage. // immediately because their page's layout is still valid.
to.offstage = to.animation.value == 0.0; 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) { // Putting a route offstage changes its animation value to 1.0. Once this
_startHeroTransition(from, to, animation, flightType); // 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. // hero flight, or divert an existing one.
void _startHeroTransition( void _startHeroTransition(
PageRoute<dynamic> from, PageRoute<dynamic> from,
PageRoute<dynamic> to, PageRoute<dynamic> to,
Animation<double> animation, Animation<double> animation,
HeroFlightDirection flightType, HeroFlightDirection flightType,
bool isUserGestureTransition,
) { ) {
// If the navigator or one of the routes subtrees was removed before this // 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. // end-of-frame callback was called, then don't actually start a transition.
...@@ -609,8 +647,8 @@ class HeroController extends NavigatorObserver { ...@@ -609,8 +647,8 @@ class HeroController extends NavigatorObserver {
final Rect navigatorRect = _globalBoundingBoxFor(navigator.context); final Rect navigatorRect = _globalBoundingBoxFor(navigator.context);
// At this point the toHeroes may have been built and laid out for the first time. // At this point the toHeroes may have been built and laid out for the first time.
final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext); final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext, isUserGestureTransition);
final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext); final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext, isUserGestureTransition);
// If the `to` route was offstage, then we're implicitly restoring its // If the `to` route was offstage, then we're implicitly restoring its
// animation value back to what it was before it was "moved" offstage. // animation value back to what it was before it was "moved" offstage.
...@@ -632,6 +670,7 @@ class HeroController extends NavigatorObserver { ...@@ -632,6 +670,7 @@ class HeroController extends NavigatorObserver {
createRectTween: createRectTween, createRectTween: createRectTween,
shuttleBuilder: shuttleBuilder:
toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder, toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder,
isUserGestureTransition: isUserGestureTransition,
); );
if (_flights[tag] != null) if (_flights[tag] != null)
......
...@@ -348,11 +348,18 @@ class NavigatorObserver { ...@@ -348,11 +348,18 @@ class NavigatorObserver {
/// The [Navigator] replaced `oldRoute` with `newRoute`. /// The [Navigator] replaced `oldRoute` with `newRoute`.
void didReplace({ Route<dynamic> newRoute, Route<dynamic> oldRoute }) { } void didReplace({ Route<dynamic> newRoute, Route<dynamic> 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 /// For example, this is called when an iOS back gesture starts.
/// to disabled hero animations during such interactions. ///
void didStartUserGesture() { } /// 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<dynamic> route, Route<dynamic> previousRoute) { }
/// User gesture is no longer controlling the [Navigator]. /// User gesture is no longer controlling the [Navigator].
/// ///
...@@ -1911,8 +1918,15 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -1911,8 +1918,15 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
void didStartUserGesture() { void didStartUserGesture() {
_userGesturesInProgress += 1; _userGesturesInProgress += 1;
if (_userGesturesInProgress == 1) { if (_userGesturesInProgress == 1) {
final Route<dynamic> route = _history.last;
final Route<dynamic> 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) for (NavigatorObserver observer in widget.observers)
observer.didStartUserGesture(); observer.didStartUserGesture(route, previousRoute);
} }
} }
......
...@@ -1015,4 +1015,110 @@ void main() { ...@@ -1015,4 +1015,110 @@ void main() {
expect(bottomBuildTimes, 2); expect(bottomBuildTimes, 2);
expect(topBuildTimes, 3); 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);
});
} }
...@@ -14,13 +14,19 @@ Key homeRouteKey = const Key('homeRoute'); ...@@ -14,13 +14,19 @@ Key homeRouteKey = const Key('homeRoute');
Key routeTwoKey = const Key('routeTwo'); Key routeTwoKey = const Key('routeTwo');
Key routeThreeKey = const Key('routeThree'); Key routeThreeKey = const Key('routeThree');
bool transitionFromUserGestures = false;
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (BuildContext context) => Material( '/': (BuildContext context) => Material(
child: ListView( child: ListView(
key: homeRouteKey, key: homeRouteKey,
children: <Widget>[ children: <Widget>[
Container(height: 100.0, width: 100.0), 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), Container(height: 100.0, width: 100.0),
FlatButton( FlatButton(
child: const Text('two'), child: const Text('two'),
...@@ -42,7 +48,11 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ ...@@ -42,7 +48,11 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
onPressed: () { Navigator.pop(context); } onPressed: () { Navigator.pop(context); }
), ),
Container(height: 150.0, width: 150.0), 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), Container(height: 150.0, width: 150.0),
FlatButton( FlatButton(
child: const Text('three'), child: const Text('three'),
...@@ -67,7 +77,11 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ ...@@ -67,7 +77,11 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 50.0), 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), Container(height: 150.0, width: 150.0),
...@@ -78,7 +92,6 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ ...@@ -78,7 +92,6 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
] ]
) )
), ),
}; };
class ThreeRoute extends MaterialPageRoute<void> { class ThreeRoute extends MaterialPageRoute<void> {
...@@ -121,6 +134,10 @@ class MyStatefulWidgetState extends State<MyStatefulWidget> { ...@@ -121,6 +134,10 @@ class MyStatefulWidgetState extends State<MyStatefulWidget> {
} }
void main() { void main() {
setUp(() {
transitionFromUserGestures = false;
});
testWidgets('Heroes animate', (WidgetTester tester) async { testWidgets('Heroes animate', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(routes: routes)); await tester.pumpWidget(MaterialApp(routes: routes));
...@@ -1335,4 +1352,89 @@ void main() { ...@@ -1335,4 +1352,89 @@ void main() {
expect(find.text('Venom'), findsOneWidget); expect(find.text('Venom'), findsOneWidget);
expect(find.text('Joker'), 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);
});
} }
...@@ -96,6 +96,7 @@ class TestObserver extends NavigatorObserver { ...@@ -96,6 +96,7 @@ class TestObserver extends NavigatorObserver {
OnObservation onPopped; OnObservation onPopped;
OnObservation onRemoved; OnObservation onRemoved;
OnObservation onReplaced; OnObservation onReplaced;
OnObservation onStartUserGesture;
@override @override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
...@@ -122,6 +123,12 @@ class TestObserver extends NavigatorObserver { ...@@ -122,6 +123,12 @@ class TestObserver extends NavigatorObserver {
if (onReplaced != null) if (onReplaced != null)
onReplaced(newRoute, oldRoute); onReplaced(newRoute, oldRoute);
} }
@override
void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onStartUserGesture != null)
onStartUserGesture(route, previousRoute);
}
} }
void main() { void main() {
...@@ -715,6 +722,38 @@ void main() { ...@@ -715,6 +722,38 @@ void main() {
expect(log, <String>['pushed / (previous is <none>)', 'pushed B (previous is /)', 'pushed C (previous is B)', 'replaced B with D']); expect(log, <String>['pushed / (previous is <none>)', 'pushed B (previous is /)', 'pushed C (previous is B)', 'replaced B with D']);
}); });
testWidgets('didStartUserGesture observable',
(WidgetTester tester) async {
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }),
'/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }),
};
Route<dynamic> observedRoute;
Route<dynamic> observedPreviousRoute;
final TestObserver observer = TestObserver()
..onStartUserGesture = (Route<dynamic> route, Route<dynamic> previousRoute) {
observedRoute = route;
observedPreviousRoute = previousRoute;
};
await tester.pumpWidget(MaterialApp(
routes: routes,
navigatorObservers: <NavigatorObserver>[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<NavigatorState>(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 { testWidgets('ModalRoute.of sets up a route to rebuild if its state changes', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
final List<String> log = <String>[]; final List<String> log = <String>[];
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment