Back swipe hero (#23320)
This commit is contained in:
parent
c7b10a2d80
commit
87ca3d52a9
@ -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> {
|
|||||||
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
|
|||||||
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> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
|||||||
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 {
|
|||||||
/// 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 {
|
|||||||
@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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
/// 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 {
|
|||||||
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
|
@override
|
||||||
void didStartUserGesture() {
|
void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
|
||||||
_questsEnabled = false;
|
assert(navigator != null);
|
||||||
}
|
assert(route != null);
|
||||||
|
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, true);
|
||||||
@override
|
|
||||||
void didStopUserGesture() {
|
|
||||||
_questsEnabled = 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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
/// 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 {
|
|||||||
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() {
|
|||||||
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');
|
|||||||
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>{
|
|||||||
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>{
|
|||||||
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>{
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class ThreeRoute extends MaterialPageRoute<void> {
|
class ThreeRoute extends MaterialPageRoute<void> {
|
||||||
@ -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() {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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() {
|
|||||||
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>[];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user