Unverified Commit 05a80eb8 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Handle infinite/NaN rects in Hero flights. Less exclamation marks. (#72946)

parent 373ec58d
...@@ -71,16 +71,6 @@ enum HeroFlightDirection { ...@@ -71,16 +71,6 @@ enum HeroFlightDirection {
pop, pop,
} }
// The bounding box for context in ancestorContext coordinate system, or in the global
// coordinate system when null.
Rect _boundingBoxFor(BuildContext context, [BuildContext? ancestorContext]) {
final RenderBox box = context.findRenderObject()! as RenderBox;
assert(box != null && box.hasSize);
return MatrixUtils.transformRect(
box.getTransformTo(ancestorContext?.findRenderObject()),
Offset.zero & box.size,
);
}
/// A widget that marks its child as being a candidate for /// A widget that marks its child as being a candidate for
/// [hero animations](https://flutter.dev/docs/development/ui/animations/hero-animations). /// [hero animations](https://flutter.dev/docs/development/ui/animations/hero-animations).
...@@ -282,7 +272,7 @@ class Hero extends StatefulWidget { ...@@ -282,7 +272,7 @@ class Hero extends StatefulWidget {
} else { } else {
// If transition is not allowed, we need to make sure hero is not hidden. // If transition is not allowed, we need to make sure hero is not hidden.
// A hero can be hidden previously due to hero transition. // A hero can be hidden previously due to hero transition.
heroState.ensurePlaceholderIsHidden(); heroState.endFlight();
} }
} }
...@@ -325,6 +315,13 @@ class Hero extends StatefulWidget { ...@@ -325,6 +315,13 @@ class Hero extends StatefulWidget {
} }
} }
/// The [Hero] widget displays different content based on whether it is in an
/// animated transition ("flight"), from/to another [Hero] with the same tag:
/// * When [startFlight] is called, the real content of this [Hero] will be
/// replaced by a "placeholder" widget.
/// * When the flight ends, the "toHero"'s [endFlight] method must be called
/// by the hero controller, so the real content of that [Hero] becomes
/// visible again when the animation completes.
class _HeroState extends State<Hero> { class _HeroState extends State<Hero> {
final GlobalKey _key = GlobalKey(); final GlobalKey _key = GlobalKey();
Size? _placeholderSize; Size? _placeholderSize;
...@@ -354,20 +351,21 @@ class _HeroState extends State<Hero> { ...@@ -354,20 +351,21 @@ class _HeroState extends State<Hero> {
}); });
} }
void ensurePlaceholderIsHidden() {
if (mounted) {
setState(() {
_placeholderSize = null;
});
}
}
// When `keepPlaceholder` is true, the placeholder will continue to be shown // When `keepPlaceholder` is true, the placeholder will continue to be shown
// after the flight ends. Otherwise the child of the Hero will become visible // after the flight ends. Otherwise the child of the Hero will become visible
// and its TickerMode will be re-enabled. // and its TickerMode will be re-enabled.
//
// This method can be safely called even when this [Hero] is currently not in
// a flight.
void endFlight({ bool keepPlaceholder = false }) { void endFlight({ bool keepPlaceholder = false }) {
if (!keepPlaceholder) { if (keepPlaceholder || _placeholderSize == null)
ensurePlaceholderIsHidden(); return;
_placeholderSize = null;
if (mounted) {
// Tell the widget to rebuild if it's mounted. _paceholderSize has already
// been updated.
setState(() {});
} }
} }
...@@ -406,11 +404,12 @@ class _HeroState extends State<Hero> { ...@@ -406,11 +404,12 @@ class _HeroState extends State<Hero> {
} }
// Everything known about a hero flight that's to be started or diverted. // Everything known about a hero flight that's to be started or diverted.
@immutable
class _HeroFlightManifest { class _HeroFlightManifest {
_HeroFlightManifest({ _HeroFlightManifest({
required this.type, required this.type,
required this.overlay, required this.overlay,
required this.navigatorRect, required this.navigatorSize,
required this.fromRoute, required this.fromRoute,
required this.toRoute, required this.toRoute,
required this.fromHero, required this.fromHero,
...@@ -422,8 +421,8 @@ class _HeroFlightManifest { ...@@ -422,8 +421,8 @@ class _HeroFlightManifest {
}) : assert(fromHero.widget.tag == toHero.widget.tag); }) : assert(fromHero.widget.tag == toHero.widget.tag);
final HeroFlightDirection type; final HeroFlightDirection type;
final OverlayState? overlay; final OverlayState overlay;
final Rect navigatorRect; final Size navigatorSize;
final PageRoute<dynamic> fromRoute; final PageRoute<dynamic> fromRoute;
final PageRoute<dynamic> toRoute; final PageRoute<dynamic> toRoute;
final _HeroState fromHero; final _HeroState fromHero;
...@@ -443,10 +442,47 @@ class _HeroFlightManifest { ...@@ -443,10 +442,47 @@ class _HeroFlightManifest {
); );
} }
Tween<Rect?> createHeroRectTween({ required Rect? begin, required Rect? end }) {
final CreateRectTween? createRectTween = toHero.widget.createRectTween ?? this.createRectTween;
return createRectTween?.call(begin, end) ?? RectTween(begin: begin, end: end);
}
// The bounding box for `context`'s render object, in `ancestorContext`'s
// render object's coordinate space.
static Rect _boundingBoxFor(BuildContext context, BuildContext? ancestorContext) {
assert(ancestorContext != null);
final RenderBox box = context.findRenderObject()! as RenderBox;
assert(box != null && box.hasSize && box.size.isFinite);
return MatrixUtils.transformRect(
box.getTransformTo(ancestorContext?.findRenderObject()),
Offset.zero & box.size,
);
}
/// The bounding box of [fromHero], in [fromRoute]'s coordinate space.
///
/// This property should only be accessed in [_HeroFlight.start].
late final Rect fromHeroLocation = _boundingBoxFor(fromHero.context, fromRoute.subtreeContext);
/// The bounding box of [toHero], in [toRoute]'s coordinate space.
///
/// This property should only be accessed in [_HeroFlight.start] or
/// [_HeroFlight.divert].
late final Rect toHeroLocation = _boundingBoxFor(toHero.context, toRoute.subtreeContext);
/// Whether this [_HeroFlightManifest] is valid and can be used to start or
/// divert a [_HeroFlight].
///
/// When starting or diverting a [_HeroFlight] with a brand new
/// [_HeroFlightManifest], this flag must be checked to ensure the [RectTween]
/// the [_HeroFlightManifest] produces does not contain coordinates that have
/// [double.infinity] or [double.nan].
late final bool isValid = toHeroLocation.isFinite && (isDiverted || fromHeroLocation.isFinite);
@override @override
String toString() { String toString() {
return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} ' return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
'to route: ${toRoute.settings} with hero: $fromHero to $toHero)'; 'to route: ${toRoute.settings} with hero: $fromHero to $toHero)${isValid ? '' : ', INVALID'}';
} }
} }
...@@ -463,28 +499,23 @@ class _HeroFlight { ...@@ -463,28 +499,23 @@ class _HeroFlight {
Animation<double> _heroOpacity = kAlwaysCompleteAnimation; Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
late ProxyAnimation _proxyAnimation; late ProxyAnimation _proxyAnimation;
_HeroFlightManifest? manifest; // The manifest will be available once `start` is called, throughout the
// flight's lifecycle.
late _HeroFlightManifest manifest;
OverlayEntry? overlayEntry; OverlayEntry? overlayEntry;
bool _aborted = false; bool _aborted = false;
Tween<Rect?> _doCreateRectTween(Rect? begin, Rect? end) {
final CreateRectTween? createRectTween = manifest!.toHero.widget.createRectTween ?? manifest!.createRectTween;
if (createRectTween != null)
return createRectTween(begin, end);
return RectTween(begin: begin, end: end);
}
static final Animatable<double> _reverseTween = Tween<double>(begin: 1.0, end: 0.0); static final Animatable<double> _reverseTween = Tween<double>(begin: 1.0, end: 0.0);
// The OverlayEntry WidgetBuilder callback for the hero's overlay. // The OverlayEntry WidgetBuilder callback for the hero's overlay.
Widget _buildOverlay(BuildContext context) { Widget _buildOverlay(BuildContext context) {
assert(manifest != null); assert(manifest != null);
shuttle ??= manifest!.shuttleBuilder( shuttle ??= manifest.shuttleBuilder(
context, context,
manifest!.animation, manifest.animation,
manifest!.type, manifest.type,
manifest!.fromHero.context, manifest.fromHero.context,
manifest!.toHero.context, manifest.toHero.context,
); );
assert(shuttle != null); assert(shuttle != null);
...@@ -492,32 +523,8 @@ class _HeroFlight { ...@@ -492,32 +523,8 @@ class _HeroFlight {
animation: _proxyAnimation, animation: _proxyAnimation,
child: shuttle, child: shuttle,
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
final RenderBox? toHeroBox = manifest!.toHero.mounted
? manifest!.toHero.context.findRenderObject() as RenderBox?
: null;
if (_aborted || toHeroBox == null || !toHeroBox.attached) {
// The toHero no longer exists or it's no longer the flight's destination.
// Continue flying while fading out.
if (_heroOpacity.isCompleted) {
_heroOpacity = _proxyAnimation.drive(
_reverseTween.chain(CurveTween(curve: Interval(_proxyAnimation.value, 1.0))),
);
}
} else if (toHeroBox.hasSize) {
// The toHero has been laid out. If it's no longer where the hero animation is
// supposed to end up then recreate the heroRect tween.
final RenderBox? finalRouteBox = manifest!.toRoute.subtreeContext?.findRenderObject() as RenderBox?;
final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (toHeroOrigin != heroRectTween.end!.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRectTween.end!.size;
heroRectTween = _doCreateRectTween(heroRectTween.begin, heroRectEnd);
}
}
final Rect rect = heroRectTween.evaluate(_proxyAnimation)!; final Rect rect = heroRectTween.evaluate(_proxyAnimation)!;
final Size size = manifest!.navigatorRect.size; final RelativeRect offsets = RelativeRect.fromSize(rect, manifest.navigatorSize);
final RelativeRect offsets = RelativeRect.fromSize(rect, size);
return Positioned( return Positioned(
top: offsets.top, top: offsets.top,
right: offsets.right, right: offsets.right,
...@@ -548,9 +555,10 @@ class _HeroFlight { ...@@ -548,9 +555,10 @@ class _HeroFlight {
// fromHero hidden. If [AnimationStatus.dismissed], the animation is // fromHero hidden. If [AnimationStatus.dismissed], the animation is
// triggered but canceled before it finishes. In this case, we keep toHero // triggered but canceled before it finishes. In this case, we keep toHero
// hidden instead. // hidden instead.
manifest!.fromHero.endFlight(keepPlaceholder: status == AnimationStatus.completed); manifest.fromHero.endFlight(keepPlaceholder: status == AnimationStatus.completed);
manifest!.toHero.endFlight(keepPlaceholder: status == AnimationStatus.dismissed); manifest.toHero.endFlight(keepPlaceholder: status == AnimationStatus.dismissed);
onFlightEnded(this); onFlightEnded(this);
_proxyAnimation.removeListener(onTick);
} }
} }
...@@ -559,7 +567,7 @@ class _HeroFlight { ...@@ -559,7 +567,7 @@ class _HeroFlight {
// The animation will not finish until the user lifts their finger, so we // The animation will not finish until the user lifts their finger, so we
// should suppress the status update if the gesture is in progress, and // should suppress the status update if the gesture is in progress, and
// delay it until the finger is lifted. // delay it until the finger is lifted.
if (manifest!.fromRoute.navigator?.userGestureInProgress != true) { if (manifest.fromRoute.navigator?.userGestureInProgress != true) {
_performAnimationUpdate(status); _performAnimationUpdate(status);
return; return;
} }
...@@ -569,7 +577,7 @@ class _HeroFlight { ...@@ -569,7 +577,7 @@ class _HeroFlight {
// The `navigator` must be non-null here, or the first if clause above would // The `navigator` must be non-null here, or the first if clause above would
// have returned from this method. // have returned from this method.
final NavigatorState navigator = manifest!.fromRoute.navigator!; final NavigatorState navigator = manifest.fromRoute.navigator!;
void delayedPerformAnimtationUpdate() { void delayedPerformAnimtationUpdate() {
assert(!navigator.userGestureInProgress); assert(!navigator.userGestureInProgress);
...@@ -583,6 +591,33 @@ class _HeroFlight { ...@@ -583,6 +591,33 @@ class _HeroFlight {
navigator.userGestureInProgressNotifier.addListener(delayedPerformAnimtationUpdate); navigator.userGestureInProgressNotifier.addListener(delayedPerformAnimtationUpdate);
} }
void onTick() {
final RenderBox? toHeroBox = (!_aborted && manifest.toHero.mounted)
? manifest.toHero.context.findRenderObject() as RenderBox?
: null;
// Try to find the new origin of the toHero, if the flight isn't aborted.
final Offset? toHeroOrigin = toHeroBox != null && toHeroBox.attached && toHeroBox.hasSize
? toHeroBox.localToGlobal(Offset.zero, ancestor: manifest.toRoute.subtreeContext?.findRenderObject() as RenderBox?)
: null;
if (toHeroOrigin != null && toHeroOrigin.isFinite) {
// If the new origin of toHero is available and also paintable, try to
// update heroRectTween with it.
if (toHeroOrigin != heroRectTween.end!.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRectTween.end!.size;
heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.begin, end: heroRectEnd);
}
} else if (_heroOpacity.isCompleted) {
// The toHero no longer exists or it's no longer the flight's destination.
// Continue flying while fading out.
_heroOpacity = _proxyAnimation.drive(
_reverseTween.chain(CurveTween(curve: Interval(_proxyAnimation.value, 1.0))),
);
}
// Update _aborted for the next animation tick.
_aborted = toHeroOrigin == null || !toHeroOrigin.isFinite;
}
// The simple case: we're either starting a push or a pop animation. // The simple case: we're either starting a push or a pop animation.
void start(_HeroFlightManifest initialManifest) { void start(_HeroFlightManifest initialManifest) {
assert(!_aborted); assert(!_aborted);
...@@ -606,34 +641,36 @@ class _HeroFlight { ...@@ -606,34 +641,36 @@ class _HeroFlight {
manifest = initialManifest; manifest = initialManifest;
if (manifest!.type == HeroFlightDirection.pop) final bool shouldIncludeChildInPlacehold;
_proxyAnimation.parent = ReverseAnimation(manifest!.animation); switch (manifest.type) {
else case HeroFlightDirection.pop:
_proxyAnimation.parent = manifest!.animation; _proxyAnimation.parent = ReverseAnimation(manifest.animation);
shouldIncludeChildInPlacehold = false;
manifest!.fromHero.startFlight(shouldIncludedChildInPlaceholder: manifest!.type == HeroFlightDirection.push); break;
manifest!.toHero.startFlight(); case HeroFlightDirection.push:
_proxyAnimation.parent = manifest.animation;
heroRectTween = _doCreateRectTween( shouldIncludeChildInPlacehold = true;
_boundingBoxFor(manifest!.fromHero.context, manifest!.fromRoute.subtreeContext), break;
_boundingBoxFor(manifest!.toHero.context, manifest!.toRoute.subtreeContext), }
);
overlayEntry = OverlayEntry(builder: _buildOverlay); heroRectTween = manifest.createHeroRectTween(begin: manifest.fromHeroLocation, end: manifest.toHeroLocation);
manifest!.overlay!.insert(overlayEntry!); manifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: shouldIncludeChildInPlacehold);
manifest.toHero.startFlight();
manifest.overlay.insert(overlayEntry = OverlayEntry(builder: _buildOverlay));
_proxyAnimation.addListener(onTick);
} }
// While this flight's hero was in transition a push or a pop occurred for // While this flight's hero was in transition a push or a pop occurred for
// routes with the same hero. Redirect the in-flight hero to the new toRoute. // routes with the same hero. Redirect the in-flight hero to the new toRoute.
void divert(_HeroFlightManifest newManifest) { void divert(_HeroFlightManifest newManifest) {
assert(manifest!.tag == newManifest.tag); assert(manifest.tag == newManifest.tag);
if (manifest!.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) { if (manifest.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) {
// A push flight was interrupted by a pop. // A push flight was interrupted by a pop.
assert(newManifest.animation.status == AnimationStatus.reverse); assert(newManifest.animation.status == AnimationStatus.reverse);
assert(manifest!.fromHero == newManifest.toHero); assert(manifest.fromHero == newManifest.toHero);
assert(manifest!.toHero == newManifest.fromHero); assert(manifest.toHero == newManifest.fromHero);
assert(manifest!.fromRoute == newManifest.toRoute); assert(manifest.fromRoute == newManifest.toRoute);
assert(manifest!.toRoute == newManifest.fromRoute); assert(manifest.toRoute == newManifest.fromRoute);
// The same heroRect tween is used in reverse, rather than creating // The same heroRect tween is used in reverse, rather than creating
// a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin). // a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
...@@ -642,39 +679,36 @@ class _HeroFlight { ...@@ -642,39 +679,36 @@ class _HeroFlight {
// path to be the same (in reverse) as the push flight path. // path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = ReverseAnimation(newManifest.animation); _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
heroRectTween = ReverseTween<Rect?>(heroRectTween); heroRectTween = ReverseTween<Rect?>(heroRectTween);
} else if (manifest!.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) { } else if (manifest.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) {
// A pop flight was interrupted by a push. // A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward); assert(newManifest.animation.status == AnimationStatus.forward);
assert(manifest!.toHero == newManifest.fromHero); assert(manifest.toHero == newManifest.fromHero);
assert(manifest!.toRoute == newManifest.fromRoute); assert(manifest.toRoute == newManifest.fromRoute);
_proxyAnimation.parent = newManifest.animation.drive( _proxyAnimation.parent = newManifest.animation.drive(
Tween<double>( Tween<double>(
begin: manifest!.animation.value, begin: manifest.animation.value,
end: 1.0, end: 1.0,
), ),
); );
if (manifest!.fromHero != newManifest.toHero) { if (manifest.fromHero != newManifest.toHero) {
manifest!.fromHero.endFlight(keepPlaceholder: true); manifest.fromHero.endFlight(keepPlaceholder: true);
newManifest.toHero.startFlight(); newManifest.toHero.startFlight();
heroRectTween = _doCreateRectTween( heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.end, end: newManifest.toHeroLocation);
heroRectTween.end,
_boundingBoxFor(newManifest.toHero.context, newManifest.toRoute.subtreeContext),
);
} else { } else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203. // TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRectTween = _doCreateRectTween(heroRectTween.end, heroRectTween.begin); heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.end, end: heroRectTween.begin);
} }
} else { } else {
// A push or a pop flight is heading to a new route, i.e. // A push or a pop flight is heading to a new route, i.e.
// manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push || // manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push ||
// manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.pop // manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.pop
assert(manifest!.fromHero != newManifest.fromHero); assert(manifest.fromHero != newManifest.fromHero);
assert(manifest!.toHero != newManifest.toHero); assert(manifest.toHero != newManifest.toHero);
heroRectTween = _doCreateRectTween( heroRectTween = manifest.createHeroRectTween(
heroRectTween.evaluate(_proxyAnimation), begin: heroRectTween.evaluate(_proxyAnimation),
_boundingBoxFor(newManifest.toHero.context, newManifest.toRoute.subtreeContext), end: newManifest.toHeroLocation,
); );
shuttle = null; shuttle = null;
...@@ -683,8 +717,8 @@ class _HeroFlight { ...@@ -683,8 +717,8 @@ class _HeroFlight {
else else
_proxyAnimation.parent = newManifest.animation; _proxyAnimation.parent = newManifest.animation;
manifest!.fromHero.endFlight(keepPlaceholder: true); manifest.fromHero.endFlight(keepPlaceholder: true);
manifest!.toHero.endFlight(keepPlaceholder: true); manifest.toHero.endFlight(keepPlaceholder: true);
// Let the heroes in each of the routes rebuild with their placeholders. // Let the heroes in each of the routes rebuild with their placeholders.
newManifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push); newManifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push);
...@@ -695,7 +729,6 @@ class _HeroFlight { ...@@ -695,7 +729,6 @@ class _HeroFlight {
overlayEntry!.markNeedsBuild(); overlayEntry!.markNeedsBuild();
} }
_aborted = false;
manifest = newManifest; manifest = newManifest;
} }
...@@ -705,9 +738,9 @@ class _HeroFlight { ...@@ -705,9 +738,9 @@ class _HeroFlight {
@override @override
String toString() { String toString() {
final RouteSettings from = manifest!.fromRoute.settings; final RouteSettings from = manifest.fromRoute.settings;
final RouteSettings to = manifest!.toRoute.settings; final RouteSettings to = manifest.toRoute.settings;
final Object tag = manifest!.tag; final Object tag = manifest.tag;
return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})'; return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})';
} }
} }
...@@ -770,14 +803,14 @@ class HeroController extends NavigatorObserver { ...@@ -770,14 +803,14 @@ class HeroController extends NavigatorObserver {
if (navigator!.userGestureInProgress) if (navigator!.userGestureInProgress)
return; return;
// If the user horizontal drag gesture initiated the flight (i.e. the back swipe) // When the user gesture ends, if the user horizontal drag gesture initiated
// didn't move towards the pop direction at all, the animation will not play // the flight (i.e. the back swipe) didn't move towards the pop direction at
// and thus the status update callback _handleAnimationUpdate will never be // all, the animation will not play and thus the status update callback
// called when the gesture finishes. In this case the initiated flight needs // _handleAnimationUpdate will never be called when the gesture finishes. In
// to be manually invalidated. // this case the initiated flight needs to be manually invalidated.
bool isInvalidFlight(_HeroFlight flight) { bool isInvalidFlight(_HeroFlight flight) {
return flight.manifest!.isUserGestureTransition return flight.manifest.isUserGestureTransition
&& flight.manifest!.type == HeroFlightDirection.pop && flight.manifest.type == HeroFlightDirection.pop
&& flight._proxyAnimation.isDismissed; && flight._proxyAnimation.isDismissed;
} }
...@@ -849,63 +882,89 @@ class HeroController extends NavigatorObserver { ...@@ -849,63 +882,89 @@ class HeroController extends NavigatorObserver {
HeroFlightDirection flightType, HeroFlightDirection flightType,
bool isUserGestureTransition, 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.
if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
to.offstage = false; // in case we set this in _maybeStartHeroTransition
return;
}
final Rect navigatorRect = _boundingBoxFor(navigator!.context);
// 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!, isUserGestureTransition, navigator!);
final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext!, isUserGestureTransition, navigator!);
// 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.
to.offstage = false; to.offstage = false;
for (final Object tag in fromHeroes.keys) { final NavigatorState? navigator = this.navigator;
if (toHeroes[tag] != null) { final OverlayState? overlay = navigator?.overlay;
final HeroFlightShuttleBuilder? fromShuttleBuilder = fromHeroes[tag]!.widget.flightShuttleBuilder; // If the navigator or the overlay was removed before this end-of-frame
final HeroFlightShuttleBuilder? toShuttleBuilder = toHeroes[tag]!.widget.flightShuttleBuilder; // callback was called, then don't actually start a transition, and we don'
final bool isDiverted = _flights[tag] != null; // t have to worry about any Hero widget we might have hidden in a previous
// flight, or onging flights.
final _HeroFlightManifest manifest = _HeroFlightManifest( if (navigator == null || overlay == null)
type: flightType, return;
overlay: navigator!.overlay,
navigatorRect: navigatorRect,
fromRoute: from,
toRoute: to,
fromHero: fromHeroes[tag]!,
toHero: toHeroes[tag]!,
createRectTween: createRectTween,
shuttleBuilder:
toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder,
isUserGestureTransition: isUserGestureTransition,
isDiverted: isDiverted,
);
if (isDiverted) final RenderObject? navigatorRenderObject = navigator.context.findRenderObject();
_flights[tag]!.divert(manifest);
else if (navigatorRenderObject is! RenderBox) {
assert(false, 'Navigator $navigator has an invalid RenderObject type ${navigatorRenderObject.runtimeType}.');
return;
}
assert(navigatorRenderObject.hasSize);
// At this point, the toHeroes may have been built and laid out for the first time.
//
// If `fromSubtreeContext` is null, call endFlight on all toHeroes, for good measure.
// If `toSubtreeContext` is null abort existingFlights.
final BuildContext? fromSubtreeContext = from.subtreeContext;
final Map<Object, _HeroState> fromHeroes = fromSubtreeContext != null
? Hero._allHeroesFor(fromSubtreeContext, isUserGestureTransition, navigator)
: const <Object, _HeroState>{};
final BuildContext? toSubtreeContext = to.subtreeContext;
final Map<Object, _HeroState> toHeroes = toSubtreeContext != null
? Hero._allHeroesFor(toSubtreeContext, isUserGestureTransition, navigator)
: const <Object, _HeroState>{};
for (final MapEntry<Object, _HeroState> fromHeroEntry in fromHeroes.entries) {
final Object tag = fromHeroEntry.key;
final _HeroState fromHero = fromHeroEntry.value;
final _HeroState? toHero = toHeroes[tag];
final _HeroFlight? existingFlight = _flights[tag];
final _HeroFlightManifest? manifest = toHero == null
? null
: _HeroFlightManifest(
type: flightType,
overlay: overlay,
navigatorSize: navigatorRenderObject.size,
fromRoute: from,
toRoute: to,
fromHero: fromHero,
toHero: toHero,
createRectTween: createRectTween,
shuttleBuilder: fromHero.widget.flightShuttleBuilder
?? toHero.widget.flightShuttleBuilder
?? _defaultHeroFlightShuttleBuilder,
isUserGestureTransition: isUserGestureTransition,
isDiverted: existingFlight != null,
);
// Only proceed with a valid manifest. Otherwise abort the existing
// flight, and call endFlight when this for loop finishes.
if (manifest != null && manifest.isValid) {
toHeroes.remove(tag);
if (existingFlight != null) {
existingFlight.divert(manifest);
} else {
_flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest); _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
} else if (_flights[tag] != null) { }
_flights[tag]!.abort(); } else {
existingFlight?.abort();
} }
} }
// If the from hero is gone, the flight won't start and the to hero needs to // The remaining entries in toHeroes are those failed to participate in a
// be put on stage again. // new flight (for not having a valid manifest).
for (final Object tag in toHeroes.keys) { //
if (fromHeroes[tag] == null) // This can happen in a route pop transition when a fromHero is no longer
toHeroes[tag]!.ensurePlaceholderIsHidden(); // mounted, or kept alive by the [KeepAlive] mechanism but no longer visible.
} // TODO(LongCatIsLooong): resume aborted flights: https://github.com/flutter/flutter/issues/72947
for (final _HeroState toHero in toHeroes.values)
toHero.endFlight();
} }
void _handleFlightEnded(_HeroFlight flight) { void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest!.tag); _flights.remove(flight.manifest.tag);
} }
static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = ( static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = (
......
...@@ -2780,4 +2780,212 @@ Future<void> main() async { ...@@ -2780,4 +2780,212 @@ Future<void> main() async {
expect(find.byKey(secondKey), isInCard); expect(find.byKey(secondKey), isInCard);
expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isOnstage);
}); });
testWidgets('kept alive Hero does not throw when the transition begins', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(
body: ListView(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
addSemanticIndexes: false,
children: <Widget>[
const KeepAlive(
keepAlive: true,
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
Container(height: 1000.0),
],
),
),
),
);
// Scroll to make the Hero invisible.
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
await tester.pump();
expect(find.byType(TextField), findsNothing);
navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return const Scaffold(
body: Center(
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
);
},
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// The Hero on the new route should be visible .
expect(find.byType(Placeholder), findsOneWidget);
});
testWidgets('toHero becomes unpaintable after the transition begins', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final ScrollController controller = ScrollController();
RenderOpacity? findRenderOpacity() {
AbstractNode? parent = tester.renderObject(find.byType(Placeholder));
while (parent is RenderObject && parent is! RenderOpacity) {
parent = parent.parent;
}
return parent is RenderOpacity ? parent : null;
}
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(
body: ListView(
controller: controller,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
addSemanticIndexes: false,
children: <Widget>[
const KeepAlive(
keepAlive: true,
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
Container(height: 1000.0),
],
),
),
),
);
navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return const Scaffold(
body: Center(
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
);
},
),
);
await tester.pump();
await tester.pumpAndSettle();
// Pop the new route, and before the animation finishes we scroll the toHero
// to make it unpaintable.
navigatorKey.currentState?.pop();
await tester.pump();
controller.jumpTo(1000);
// Starts Hero animation and scroll animation almost simutaneously.
// Scroll to make the Hero invisible.
await tester.pump();
expect(findRenderOpacity()?.opacity, anyOf(isNull, 1.0));
// In this frame the Hero animation finds out the toHero is not paintable,
// and starts fading.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(findRenderOpacity()?.opacity, lessThan(1.0));
await tester.pumpAndSettle();
// The Hero on the new route should be invisible.
expect(find.byType(Placeholder), findsNothing);
});
testWidgets('diverting to a keepalive but unpaintable hero', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
CupertinoApp(
navigatorKey: navigatorKey,
home: CupertinoPageScaffold(
child: ListView(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
addSemanticIndexes: false,
children: <Widget>[
const KeepAlive(
keepAlive: true,
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
Container(height: 1000.0),
],
),
),
),
);
// Scroll to make the Hero invisible.
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
await tester.pump();
expect(find.byType(Placeholder), findsNothing);
expect(find.byType(Placeholder, skipOffstage: false), findsOneWidget);
navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return const Scaffold(
body: Center(
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
);
},
),
);
await tester.pumpAndSettle();
// Yet another route that contains Hero 'a'.
navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return const Scaffold(
body: Center(
child: Hero(
tag: 'a',
child: Placeholder(),
),
),
);
},
),
);
await tester.pumpAndSettle();
// Pop both routes.
navigatorKey.currentState?.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
navigatorKey.currentState?.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(find.byType(Placeholder), findsOneWidget);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
} }
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