blob: 572beb195e8a438a931bda6ee672af29b88e2331 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'navigator.dart';
import 'overlay.dart';
import 'pages.dart';
import 'routes.dart';
import 'ticker_provider.dart' show TickerMode;
import 'transitions.dart';
/// Signature for a function that takes two [Rect] instances and returns a
/// [RectTween] that transitions between them.
///
/// This is typically used with a [HeroController] to provide an animation for
/// [Hero] positions that looks nicer than a linear movement. For example, see
/// [MaterialRectArcTween].
typedef CreateRectTween = Tween<Rect?> Function(Rect? begin, Rect? end);
/// Signature for a function that builds a [Hero] placeholder widget given a
/// child and a [Size].
///
/// The child can optionally be part of the returned widget tree. The returned
/// widget should typically be constrained to [heroSize], if it doesn't do so
/// implicitly.
///
/// See also:
///
/// * [TransitionBuilder], which is similar but only takes a [BuildContext]
/// and a child widget.
typedef HeroPlaceholderBuilder = Widget Function(
BuildContext context,
Size heroSize,
Widget child,
);
/// A function that lets [Hero]es self supply a [Widget] that is shown during the
/// hero's flight from one route to another instead of default (which is to
/// show the destination route's instance of the Hero).
typedef HeroFlightShuttleBuilder = Widget Function(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
);
typedef _OnFlightEnded = void Function(_HeroFlight flight);
/// Direction of the hero's flight based on the navigation operation.
enum HeroFlightDirection {
/// A flight triggered by a route push.
///
/// The animation goes from 0 to 1.
///
/// If no custom [HeroFlightShuttleBuilder] is supplied, the top route's
/// [Hero] child is shown in flight.
push,
/// A flight triggered by a route pop.
///
/// The animation goes from 1 to 0.
///
/// If no custom [HeroFlightShuttleBuilder] is supplied, the bottom route's
/// [Hero] child is shown in flight.
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
/// [hero animations](https://flutter.dev/docs/development/ui/animations/hero-animations).
///
/// When a [PageRoute] is pushed or popped with the [Navigator], the entire
/// screen's content is replaced. An old route disappears and a new route
/// appears. If there's a common visual feature on both routes then it can
/// be helpful for orienting the user for the feature to physically move from
/// one page to the other during the routes' transition. Such an animation
/// is called a *hero animation*. The hero widgets "fly" in the Navigator's
/// overlay during the transition and while they're in-flight they're, by
/// default, not shown in their original locations in the old and new routes.
///
/// To label a widget as such a feature, wrap it in a [Hero] widget. When
/// navigation happens, the [Hero] widgets on each route are identified
/// by the [HeroController]. For each pair of [Hero] widgets that have the
/// same tag, a hero animation is triggered.
///
/// If a [Hero] is already in flight when navigation occurs, its
/// flight animation will be redirected to its new destination. The
/// widget shown in-flight during the transition is, by default, the
/// destination route's [Hero]'s child.
///
/// For a Hero animation to trigger, the Hero has to exist on the very first
/// frame of the new page's animation.
///
/// Routes must not contain more than one [Hero] for each [tag].
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=Be9UH1kXFDw}
///
/// ## Discussion
///
/// Heroes and the [Navigator]'s [Overlay] [Stack] must be axis-aligned for
/// all this to work. The top left and bottom right coordinates of each animated
/// Hero will be converted to global coordinates and then from there converted
/// to that [Stack]'s coordinate space, and the entire Hero subtree will, for
/// the duration of the animation, be lifted out of its original place, and
/// positioned on that stack. If the [Hero] isn't axis aligned, this is going to
/// fail in a rather ugly fashion. Don't rotate your heroes!
///
/// To make the animations look good, it's critical that the widget tree for the
/// hero in both locations be essentially identical. The widget of the *target*
/// is, by default, used to do the transition: when going from route A to route
/// B, route B's hero's widget is placed over route A's hero's widget. If a
/// [flightShuttleBuilder] is supplied, its output widget is shown during the
/// flight transition instead.
///
/// By default, both route A and route B's heroes are hidden while the
/// transitioning widget is animating in-flight above the 2 routes.
/// [placeholderBuilder] can be used to show a custom widget in their place
/// instead once the transition has taken flight.
///
/// During the transition, the transition widget is animated to route B's hero's
/// position, and then the widget is inserted into route B. When going back from
/// B to A, route A's hero's widget is, by default, placed over where route B's
/// hero's widget was, and then the animation goes the other way.
///
/// ### Nested Navigators
///
/// If either or both routes contain nested [Navigator]s, only [Hero]es
/// contained in the top-most routes (as defined by [Route.isCurrent]) *of those
/// nested [Navigator]s* are considered for animation. Just like in the
/// non-nested case the top-most routes containing these [Hero]es in the nested
/// [Navigator]s have to be [PageRoute]s.
///
/// ## Parts of a Hero Transition
///
/// ![Diagrams with parts of the Hero transition.](https://flutter.github.io/assets-for-api-docs/assets/interaction/heroes.png)
class Hero extends StatefulWidget {
/// Create a hero.
///
/// The [tag] and [child] parameters must not be null.
/// The [child] parameter and all of the its descendants must not be [Hero]es.
const Hero({
Key? key,
required this.tag,
this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
this.transitionOnUserGestures = false,
required this.child,
}) : assert(tag != null),
assert(transitionOnUserGestures != null),
assert(child != null),
super(key: key);
/// The identifier for this particular hero. If the tag of this hero matches
/// the tag of a hero on a [PageRoute] that we're navigating to or from, then
/// a hero animation will be triggered.
final Object tag;
/// Defines how the destination hero's bounds change as it flies from the starting
/// route to the destination route.
///
/// A hero flight begins with the destination hero's [child] aligned with the
/// starting hero's child. The [Tween<Rect>] returned by this callback is used
/// to compute the hero's bounds as the flight animation's value goes from 0.0
/// to 1.0.
///
/// If this property is null, the default, then the value of
/// [HeroController.createRectTween] is used. The [HeroController] created by
/// [MaterialApp] creates a [MaterialRectArcTween].
final CreateRectTween? createRectTween;
/// The widget subtree that will "fly" from one route to another during a
/// [Navigator] push or pop transition.
///
/// The appearance of this subtree should be similar to the appearance of
/// the subtrees of any other heroes in the application with the same [tag].
/// Changes in scale and aspect ratio work well in hero animations, changes
/// in layout or composition do not.
///
/// {@macro flutter.widgets.child}
final Widget child;
/// Optional override to supply a widget that's shown during the hero's flight.
///
/// This in-flight widget can depend on the route transition's animation as
/// well as the incoming and outgoing routes' [Hero] descendants' widgets and
/// layout.
///
/// When both the source and destination [Hero]es provide a [flightShuttleBuilder],
/// the destination's [flightShuttleBuilder] takes precedence.
///
/// If none is provided, the destination route's Hero child is shown in-flight
/// by default.
///
/// ## Limitations
///
/// If a widget built by [flightShuttleBuilder] takes part in a [Navigator]
/// push transition, that widget or its descendants must not have any
/// [GlobalKey] that is used in the source Hero's descendant widgets. That is
/// because both subtrees will be included in the widget tree during the Hero
/// flight animation, and [GlobalKey]s must be unique across the entire widget
/// tree.
///
/// If the said [GlobalKey] is essential to your application, consider providing
/// a custom [placeholderBuilder] for the source Hero, to avoid the [GlobalKey]
/// collision, such as a builder that builds an empty [SizedBox], keeping the
/// Hero [child]'s original size.
final HeroFlightShuttleBuilder? flightShuttleBuilder;
/// Placeholder widget left in place as the Hero's [child] once the flight takes
/// off.
///
/// By default the placeholder widget is an empty [SizedBox] keeping the Hero
/// child's original size, unless this Hero is a source Hero of a [Navigator]
/// push transition, in which case [child] will be a descendant of the placeholder
/// and will be kept [Offstage] during the Hero's flight.
final HeroPlaceholderBuilder? 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]es 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 that
// should be considered for animation when `navigator` transitions from one
// PageRoute to another.
static Map<Object, _HeroState> _allHeroesFor(
BuildContext context,
bool isUserGestureTransition,
NavigatorState navigator,
) {
assert(context != null);
assert(isUserGestureTransition != null);
assert(navigator != null);
final Map<Object, _HeroState> result = <Object, _HeroState>{};
void inviteHero(StatefulElement hero, Object tag) {
assert(() {
if (result.containsKey(tag)) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('There are multiple heroes that share the same tag within a subtree.'),
ErrorDescription(
'Within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), '
'each Hero must have a unique non-null tag.\n'
'In this case, multiple heroes had the following tag: $tag\n'
),
DiagnosticsProperty<StatefulElement>('Here is the subtree for one of the offending heroes', hero, linePrefix: '# ', style: DiagnosticsTreeStyle.dense),
]);
}
return true;
}());
final Hero heroWidget = hero.widget as Hero;
final _HeroState heroState = hero.state as _HeroState;
if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) {
result[tag] = heroState;
} else {
// If transition is not allowed, we need to make sure hero is not hidden.
// A hero can be hidden previously due to hero transition.
heroState.ensurePlaceholderIsHidden();
}
}
void visitor(Element element) {
final Widget widget = element.widget;
if (widget is Hero) {
final StatefulElement hero = element as StatefulElement;
final Object tag = widget.tag;
assert(tag != null);
if (Navigator.of(hero) == navigator) {
inviteHero(hero, tag);
} else {
// The nearest navigator to the Hero is not the Navigator that is
// currently transitioning from one route to another. This means
// the Hero is inside a nested Navigator and should only be
// considered for animation if it is part of the top-most route in
// that nested Navigator and if that route is also a PageRoute.
final ModalRoute<Object?>? heroRoute = ModalRoute.of(hero);
if (heroRoute != null && heroRoute is PageRoute && heroRoute.isCurrent) {
inviteHero(hero, tag);
}
}
}
element.visitChildren(visitor);
}
context.visitChildElements(visitor);
return result;
}
@override
_HeroState createState() => _HeroState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Object>('tag', tag));
}
}
class _HeroState extends State<Hero> {
final GlobalKey _key = GlobalKey();
Size? _placeholderSize;
// Whether the placeholder widget should wrap the hero's child widget as its
// own child, when `_placeholderSize` is non-null (i.e. the hero is currently
// in its flight animation). See `startFlight`.
bool _shouldIncludeChild = true;
// The `shouldIncludeChildInPlaceholder` flag dictates if the child widget of
// this hero should be included in the placeholder widget as a descendant.
//
// When a new hero flight animation takes place, a placeholder widget
// needs to be built to replace the original hero widget. When
// `shouldIncludeChildInPlaceholder` is set to true and `widget.placeholderBuilder`
// is null, the placeholder widget will include the original hero's child
// widget as a descendant, allowing the original element tree to be preserved.
//
// It is typically set to true for the *from* hero in a push transition,
// and false otherwise.
void startFlight({ bool shouldIncludedChildInPlaceholder = false }) {
_shouldIncludeChild = shouldIncludedChildInPlaceholder;
assert(mounted);
final RenderBox box = context.findRenderObject()! as RenderBox;
assert(box != null && box.hasSize);
setState(() {
_placeholderSize = box.size;
});
}
void ensurePlaceholderIsHidden() {
if (mounted) {
setState(() {
_placeholderSize = null;
});
}
}
// When `keepPlaceholder` is true, the placeholder will continue to be shown
// after the flight ends. Otherwise the child of the Hero will become visible
// and its TickerMode will be re-enabled.
void endFlight({ bool keepPlaceholder = false }) {
if (!keepPlaceholder) {
ensurePlaceholderIsHidden();
}
}
@override
Widget build(BuildContext context) {
assert(
context.findAncestorWidgetOfExactType<Hero>() == null,
'A Hero widget cannot be the descendant of another Hero widget.'
);
final bool showPlaceholder = _placeholderSize != null;
if (showPlaceholder && widget.placeholderBuilder != null) {
return widget.placeholderBuilder!(context, _placeholderSize!, widget.child);
}
if (showPlaceholder && !_shouldIncludeChild) {
return SizedBox(
width: _placeholderSize!.width,
height: _placeholderSize!.height,
);
}
return SizedBox(
width: _placeholderSize?.width,
height: _placeholderSize?.height,
child: Offstage(
offstage: showPlaceholder,
child: TickerMode(
enabled: !showPlaceholder,
child: KeyedSubtree(key: _key, child: widget.child),
),
),
);
}
}
// Everything known about a hero flight that's to be started or diverted.
class _HeroFlightManifest {
_HeroFlightManifest({
required this.type,
required this.overlay,
required this.navigatorRect,
required this.fromRoute,
required this.toRoute,
required this.fromHero,
required this.toHero,
required this.createRectTween,
required this.shuttleBuilder,
required this.isUserGestureTransition,
required this.isDiverted,
}) : assert(fromHero.widget.tag == toHero.widget.tag);
final HeroFlightDirection type;
final OverlayState? overlay;
final Rect navigatorRect;
final PageRoute<dynamic> fromRoute;
final PageRoute<dynamic> toRoute;
final _HeroState fromHero;
final _HeroState toHero;
final CreateRectTween? createRectTween;
final HeroFlightShuttleBuilder shuttleBuilder;
final bool isUserGestureTransition;
final bool isDiverted;
Object get tag => fromHero.widget.tag;
Animation<double> get animation {
return CurvedAnimation(
parent: (type == HeroFlightDirection.push) ? toRoute.animation! : fromRoute.animation!,
curve: Curves.fastOutSlowIn,
reverseCurve: isDiverted ? null : Curves.fastOutSlowIn.flipped,
);
}
@override
String toString() {
return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
'to route: ${toRoute.settings} with hero: $fromHero to $toHero)';
}
}
// Builds the in-flight hero widget.
class _HeroFlight {
_HeroFlight(this.onFlightEnded) {
_proxyAnimation = ProxyAnimation()..addStatusListener(_handleAnimationUpdate);
}
final _OnFlightEnded onFlightEnded;
late Tween<Rect?> heroRectTween;
Widget? shuttle;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
late ProxyAnimation _proxyAnimation;
_HeroFlightManifest? manifest;
OverlayEntry? overlayEntry;
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);
// The OverlayEntry WidgetBuilder callback for the hero's overlay.
Widget _buildOverlay(BuildContext context) {
assert(manifest != null);
shuttle ??= manifest!.shuttleBuilder(
context,
manifest!.animation,
manifest!.type,
manifest!.fromHero.context,
manifest!.toHero.context,
);
assert(shuttle != null);
return AnimatedBuilder(
animation: _proxyAnimation,
child: shuttle,
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 Size size = manifest!.navigatorRect.size;
final RelativeRect offsets = RelativeRect.fromSize(rect, size);
return Positioned(
top: offsets.top,
right: offsets.right,
bottom: offsets.bottom,
left: offsets.left,
child: IgnorePointer(
child: RepaintBoundary(
child: Opacity(
opacity: _heroOpacity.value,
child: child,
),
),
),
);
},
);
}
void _handleAnimationUpdate(AnimationStatus status) {
// The animation will not finish until the user lifts their finger, so we
// should ignore the status update if the gesture is in progress.
//
// This also relies on the animation to update its status at the end of the
// gesture. See the _CupertinoBackGestureController.dragEnd for how
// cupertino page route achieves that.
if (manifest!.fromRoute.navigator?.userGestureInProgress == true)
return;
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
_proxyAnimation.parent = null;
assert(overlayEntry != null);
overlayEntry!.remove();
overlayEntry = null;
// We want to keep the hero underneath the current page hidden. If
// [AnimationStatus.completed], toHero will be the one on top and we keep
// fromHero hidden. If [AnimationStatus.dismissed], the animation is
// triggered but canceled before it finishes. In this case, we keep toHero
// hidden instead.
manifest!.fromHero.endFlight(keepPlaceholder: status == AnimationStatus.completed);
manifest!.toHero.endFlight(keepPlaceholder: status == AnimationStatus.dismissed);
onFlightEnded(this);
}
}
// The simple case: we're either starting a push or a pop animation.
void start(_HeroFlightManifest initialManifest) {
assert(!_aborted);
assert(() {
final Animation<double> initial = initialManifest.animation;
assert(initial != null);
final HeroFlightDirection type = initialManifest.type;
assert(type != null);
switch (type) {
case HeroFlightDirection.pop:
return initial.value == 1.0 && initialManifest.isUserGestureTransition
// During user gesture transitions, the animation controller isn't
// driving the reverse transition, but should still be in a previously
// completed stage with the initial value at 1.0.
? initial.status == AnimationStatus.completed
: initial.status == AnimationStatus.reverse;
case HeroFlightDirection.push:
return initial.value == 0.0 && initial.status == AnimationStatus.forward;
}
}());
manifest = initialManifest;
if (manifest!.type == HeroFlightDirection.pop)
_proxyAnimation.parent = ReverseAnimation(manifest!.animation);
else
_proxyAnimation.parent = manifest!.animation;
manifest!.fromHero.startFlight(shouldIncludedChildInPlaceholder: manifest!.type == HeroFlightDirection.push);
manifest!.toHero.startFlight();
heroRectTween = _doCreateRectTween(
_boundingBoxFor(manifest!.fromHero.context, manifest!.fromRoute.subtreeContext),
_boundingBoxFor(manifest!.toHero.context, manifest!.toRoute.subtreeContext),
);
overlayEntry = OverlayEntry(builder: _buildOverlay);
manifest!.overlay!.insert(overlayEntry!);
}
// 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.
void divert(_HeroFlightManifest newManifest) {
assert(manifest!.tag == newManifest.tag);
if (manifest!.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) {
// A push flight was interrupted by a pop.
assert(newManifest.animation.status == AnimationStatus.reverse);
assert(manifest!.fromHero == newManifest.toHero);
assert(manifest!.toHero == newManifest.fromHero);
assert(manifest!.fromRoute == newManifest.toRoute);
assert(manifest!.toRoute == newManifest.fromRoute);
// The same heroRect tween is used in reverse, rather than creating
// a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
// That's because tweens like MaterialRectArcTween may create a different
// path for swapped begin and end parameters. We want the pop flight
// path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = ReverseAnimation(newManifest.animation);
heroRectTween = ReverseTween<Rect?>(heroRectTween);
} else if (manifest!.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) {
// A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward);
assert(manifest!.toHero == newManifest.fromHero);
assert(manifest!.toRoute == newManifest.fromRoute);
_proxyAnimation.parent = newManifest.animation.drive(
Tween<double>(
begin: manifest!.animation.value,
end: 1.0,
),
);
if (manifest!.fromHero != newManifest.toHero) {
manifest!.fromHero.endFlight(keepPlaceholder: true);
newManifest.toHero.startFlight();
heroRectTween = _doCreateRectTween(
heroRectTween.end,
_boundingBoxFor(newManifest.toHero.context, newManifest.toRoute.subtreeContext),
);
} else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRectTween = _doCreateRectTween(heroRectTween.end, heroRectTween.begin);
}
} else {
// 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.pop && newManifest.type == _HeroFlightType.pop
assert(manifest!.fromHero != newManifest.fromHero);
assert(manifest!.toHero != newManifest.toHero);
heroRectTween = _doCreateRectTween(
heroRectTween.evaluate(_proxyAnimation),
_boundingBoxFor(newManifest.toHero.context, newManifest.toRoute.subtreeContext),
);
shuttle = null;
if (newManifest.type == HeroFlightDirection.pop)
_proxyAnimation.parent = ReverseAnimation(newManifest.animation);
else
_proxyAnimation.parent = newManifest.animation;
manifest!.fromHero.endFlight(keepPlaceholder: true);
manifest!.toHero.endFlight(keepPlaceholder: true);
// Let the heroes in each of the routes rebuild with their placeholders.
newManifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push);
newManifest.toHero.startFlight();
// Let the transition overlay on top of the routes also rebuild since
// we cleared the old shuttle.
overlayEntry!.markNeedsBuild();
}
_aborted = false;
manifest = newManifest;
}
void abort() {
_aborted = true;
}
@override
String toString() {
final RouteSettings from = manifest!.fromRoute.settings;
final RouteSettings to = manifest!.toRoute.settings;
final Object tag = manifest!.tag;
return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})';
}
}
/// A [Navigator] observer that manages [Hero] transitions.
///
/// An instance of [HeroController] should be used in [Navigator.observers].
/// This is done automatically by [MaterialApp].
class HeroController extends NavigatorObserver {
/// Creates a hero controller with the given [RectTween] constructor if any.
///
/// The [createRectTween] argument is optional. If null, the controller uses a
/// linear [Tween<Rect>].
HeroController({ this.createRectTween });
/// Used to create [RectTween]s that interpolate the position of heroes in flight.
///
/// If null, the controller uses a linear [RectTween].
final CreateRectTween? createRectTween;
// All of the heroes that are currently in the overlay and in motion.
// Indexed by the hero tag.
final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push, false);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
assert(navigator != null);
assert(route != null);
// Don't trigger another flight when a pop is committed as a user gesture
// back swipe is snapped.
if (!navigator!.userGestureInProgress)
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, false);
}
@override
void didReplace({ Route<dynamic>? newRoute, Route<dynamic>? oldRoute }) {
assert(navigator != null);
if (newRoute?.isCurrent == true) {
// Only run hero animations if the top-most route got replaced.
_maybeStartHeroTransition(oldRoute, newRoute, HeroFlightDirection.push, false);
}
}
@override
void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, true);
}
@override
void didStopUserGesture() {
if (navigator!.userGestureInProgress)
return;
// If the user horizontal drag gesture initiated the flight (i.e. the back swipe)
// didn't move towards the pop direction at all, the animation will not play
// and thus the status update callback _handleAnimationUpdate will never be
// called when the gesture finishes. In this case the initiated flight needs
// to be manually invalidated.
bool isInvalidFlight(_HeroFlight flight) {
return flight.manifest!.isUserGestureTransition
&& flight.manifest!.type == HeroFlightDirection.pop
&& flight._proxyAnimation.isDismissed;
}
final List<_HeroFlight> invalidFlights = _flights.values
.where(isInvalidFlight)
.toList(growable: false);
// Treat these invalidated flights as dismissed. Calling _handleAnimationUpdate
// will also remove the flight from _flights.
for (final _HeroFlight flight in invalidFlights) {
flight._handleAnimationUpdate(AnimationStatus.dismissed);
}
}
// If we're transitioning between different page routes, start a hero transition
// after the toRoute has been laid out with its animation's value at 1.0.
void _maybeStartHeroTransition(
Route<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> to = toRoute;
final Animation<double> animation = (flightType == HeroFlightDirection.push) ? to.animation! : from.animation!;
// A user gesture may have already completed the pop, or we might be the initial route
switch (flightType) {
case HeroFlightDirection.pop:
if (animation.value == 0.0) {
return;
}
break;
case HeroFlightDirection.push:
if (animation.value == 1.0) {
return;
}
break;
}
// For pop transitions driven by a user gesture: if the "to" page has
// maintainState = true, then the hero's final dimensions can be measured
// immediately because their page's layout is still valid.
if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) {
_startHeroTransition(from, to, animation, flightType, isUserGestureTransition);
} else {
// Otherwise, delay measuring until the end of the next frame to allow
// the 'to' route to build and layout.
// Putting a route offstage changes its animation value to 1.0. Once this
// frame completes, we'll know where the heroes in the `to` route are
// going to end up, and the `to` route will go back onstage.
to.offstage = to.animation!.value == 0.0;
WidgetsBinding.instance!.addPostFrameCallback((Duration value) {
_startHeroTransition(from, to, animation, flightType, isUserGestureTransition);
});
}
}
}
// Find the matching pairs of heroes in from and to and either start or a new
// hero flight, or divert an existing one.
void _startHeroTransition(
PageRoute<dynamic> from,
PageRoute<dynamic> to,
Animation<double> animation,
HeroFlightDirection flightType,
bool isUserGestureTransition,
) {
// If the navigator or one of the routes subtrees was removed before this
// end-of-frame callback was called, then don't actually start a transition.
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
// animation value back to what it was before it was "moved" offstage.
to.offstage = false;
for (final Object tag in fromHeroes.keys) {
if (toHeroes[tag] != null) {
final HeroFlightShuttleBuilder? fromShuttleBuilder = fromHeroes[tag]!.widget.flightShuttleBuilder;
final HeroFlightShuttleBuilder? toShuttleBuilder = toHeroes[tag]!.widget.flightShuttleBuilder;
final bool isDiverted = _flights[tag] != null;
final _HeroFlightManifest manifest = _HeroFlightManifest(
type: flightType,
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)
_flights[tag]!.divert(manifest);
else
_flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
} else if (_flights[tag] != null) {
_flights[tag]!.abort();
}
}
// If the from hero is gone, the flight won't start and the to hero needs to
// be put on stage again.
for (final Object tag in toHeroes.keys) {
if (fromHeroes[tag] == null)
toHeroes[tag]!.ensurePlaceholderIsHidden();
}
}
void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest!.tag);
}
static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final Hero toHero = toHeroContext.widget as Hero;
return toHero.child;
};
}