1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:flutter/foundation.dart';
6import 'basic.dart';
7import 'binding.dart';
8import 'framework.dart';
9import 'implicit_animations.dart';
10import 'media_query.dart';
11import 'navigator.dart';
12import 'overlay.dart';
13import 'pages.dart';
14import 'routes.dart';
15import 'ticker_provider.dart' show TickerMode;
16import 'transitions.dart';
17
18/// Signature for a function that takes two [Rect] instances and returns a
19/// [RectTween] that transitions between them.
20///
21/// This is typically used with a [HeroController] to provide an animation for
22/// [Hero] positions that looks nicer than a linear movement. For example, see
23/// [MaterialRectArcTween].
24typedef CreateRectTween = Tween<Rect?> Function(Rect? begin, Rect? end);
25
26/// Signature for a function that builds a [Hero] placeholder widget given a
27/// child and a [Size].
28///
29/// The child can optionally be part of the returned widget tree. The returned
30/// widget should typically be constrained to [heroSize], if it doesn't do so
31/// implicitly.
32///
33/// See also:
34///
35/// * [TransitionBuilder], which is similar but only takes a [BuildContext]
36/// and a child widget.
37typedef HeroPlaceholderBuilder = Widget Function(
38 BuildContext context,
39 Size heroSize,
40 Widget child,
41);
42
43/// A function that lets [Hero]es self supply a [Widget] that is shown during the
44/// hero's flight from one route to another instead of default (which is to
45/// show the destination route's instance of the Hero).
46typedef HeroFlightShuttleBuilder = Widget Function(
47 BuildContext flightContext,
48 Animation<double> animation,
49 HeroFlightDirection flightDirection,
50 BuildContext fromHeroContext,
51 BuildContext toHeroContext,
52);
53
54typedef _OnFlightEnded = void Function(_HeroFlight flight);
55
56/// Direction of the hero's flight based on the navigation operation.
57enum HeroFlightDirection {
58 /// A flight triggered by a route push.
59 ///
60 /// The animation goes from 0 to 1.
61 ///
62 /// If no custom [HeroFlightShuttleBuilder] is supplied, the top route's
63 /// [Hero] child is shown in flight.
64 push,
65
66 /// A flight triggered by a route pop.
67 ///
68 /// The animation goes from 1 to 0.
69 ///
70 /// If no custom [HeroFlightShuttleBuilder] is supplied, the bottom route's
71 /// [Hero] child is shown in flight.
72 pop,
73}
74
75/// A widget that marks its child as being a candidate for
76/// [hero animations](https://flutter.dev/docs/development/ui/animations/hero-animations).
77///
78/// When a [PageRoute] is pushed or popped with the [Navigator], the entire
79/// screen's content is replaced. An old route disappears and a new route
80/// appears. If there's a common visual feature on both routes then it can
81/// be helpful for orienting the user for the feature to physically move from
82/// one page to the other during the routes' transition. Such an animation
83/// is called a *hero animation*. The hero widgets "fly" in the Navigator's
84/// overlay during the transition and while they're in-flight they're, by
85/// default, not shown in their original locations in the old and new routes.
86///
87/// To label a widget as such a feature, wrap it in a [Hero] widget. When
88/// navigation happens, the [Hero] widgets on each route are identified
89/// by the [HeroController]. For each pair of [Hero] widgets that have the
90/// same tag, a hero animation is triggered.
91///
92/// If a [Hero] is already in flight when navigation occurs, its
93/// flight animation will be redirected to its new destination. The
94/// widget shown in-flight during the transition is, by default, the
95/// destination route's [Hero]'s child.
96///
97/// For a Hero animation to trigger, the Hero has to exist on the very first
98/// frame of the new page's animation.
99///
100/// Routes must not contain more than one [Hero] for each [tag].
101///
102/// {@youtube 560 315 https://www.youtube.com/watch?v=Be9UH1kXFDw}
103///
104/// {@tool dartpad}
105/// This sample shows a [Hero] used within a [ListTile].
106///
107/// Tapping on the Hero-wrapped rectangle triggers a hero
108/// animation as a new [MaterialPageRoute] is pushed. Both the size
109/// and location of the rectangle animates.
110///
111/// Both widgets use the same [Hero.tag].
112///
113/// The Hero widget uses the matching tags to identify and execute this
114/// animation.
115///
116/// ** See code in examples/api/lib/widgets/heroes/hero.0.dart **
117/// {@end-tool}
118///
119/// {@tool dartpad}
120/// This sample shows [Hero] flight animations using default tween
121/// and custom rect tween.
122///
123/// ** See code in examples/api/lib/widgets/heroes/hero.1.dart **
124/// {@end-tool}
125///
126/// ## Discussion
127///
128/// Heroes and the [Navigator]'s [Overlay] [Stack] must be axis-aligned for
129/// all this to work. The top left and bottom right coordinates of each animated
130/// Hero will be converted to global coordinates and then from there converted
131/// to that [Stack]'s coordinate space, and the entire Hero subtree will, for
132/// the duration of the animation, be lifted out of its original place, and
133/// positioned on that stack. If the [Hero] isn't axis aligned, this is going to
134/// fail in a rather ugly fashion. Don't rotate your heroes!
135///
136/// To make the animations look good, it's critical that the widget tree for the
137/// hero in both locations be essentially identical. The widget of the *target*
138/// is, by default, used to do the transition: when going from route A to route
139/// B, route B's hero's widget is placed over route A's hero's widget. Additionally,
140/// if the [Hero] subtree changes appearance based on an [InheritedWidget] (such
141/// as [MediaQuery] or [Theme]), then the hero animation may have discontinuity
142/// at the start or the end of the animation because route A and route B provides
143/// different such [InheritedWidget]s. Consider providing a custom [flightShuttleBuilder]
144/// to ensure smooth transitions. The default [flightShuttleBuilder] interpolates
145/// [MediaQuery]'s paddings. If your [Hero] widget uses custom [InheritedWidget]s
146/// and displays a discontinuity in the animation, try to provide custom in-flight
147/// transition using [flightShuttleBuilder].
148///
149/// By default, both route A and route B's heroes are hidden while the
150/// transitioning widget is animating in-flight above the 2 routes.
151/// [placeholderBuilder] can be used to show a custom widget in their place
152/// instead once the transition has taken flight.
153///
154/// During the transition, the transition widget is animated to route B's hero's
155/// position, and then the widget is inserted into route B. When going back from
156/// B to A, route A's hero's widget is, by default, placed over where route B's
157/// hero's widget was, and then the animation goes the other way.
158///
159/// ### Nested Navigators
160///
161/// If either or both routes contain nested [Navigator]s, only [Hero]es
162/// contained in the top-most routes (as defined by [Route.isCurrent]) *of those
163/// nested [Navigator]s* are considered for animation. Just like in the
164/// non-nested case the top-most routes containing these [Hero]es in the nested
165/// [Navigator]s have to be [PageRoute]s.
166///
167/// ## Parts of a Hero Transition
168///
169/// ![Diagrams with parts of the Hero transition.](https://flutter.github.io/assets-for-api-docs/assets/interaction/heroes.png)
170class Hero extends StatefulWidget {
171 /// Create a hero.
172 ///
173 /// The [child] parameter and all of the its descendants must not be [Hero]es.
174 const Hero({
175 super.key,
176 required this.tag,
177 this.createRectTween,
178 this.flightShuttleBuilder,
179 this.placeholderBuilder,
180 this.transitionOnUserGestures = false,
181 required this.child,
182 });
183
184 /// The identifier for this particular hero. If the tag of this hero matches
185 /// the tag of a hero on a [PageRoute] that we're navigating to or from, then
186 /// a hero animation will be triggered.
187 final Object tag;
188
189 /// Defines how the destination hero's bounds change as it flies from the starting
190 /// route to the destination route.
191 ///
192 /// A hero flight begins with the destination hero's [child] aligned with the
193 /// starting hero's child. The [Tween<Rect>] returned by this callback is used
194 /// to compute the hero's bounds as the flight animation's value goes from 0.0
195 /// to 1.0.
196 ///
197 /// If this property is null, the default, then the value of
198 /// [HeroController.createRectTween] is used. The [HeroController] created by
199 /// [MaterialApp] creates a [MaterialRectArcTween].
200 final CreateRectTween? createRectTween;
201
202 /// The widget subtree that will "fly" from one route to another during a
203 /// [Navigator] push or pop transition.
204 ///
205 /// The appearance of this subtree should be similar to the appearance of
206 /// the subtrees of any other heroes in the application with the same [tag].
207 /// Changes in scale and aspect ratio work well in hero animations, changes
208 /// in layout or composition do not.
209 ///
210 /// {@macro flutter.widgets.ProxyWidget.child}
211 final Widget child;
212
213 /// Optional override to supply a widget that's shown during the hero's flight.
214 ///
215 /// This in-flight widget can depend on the route transition's animation as
216 /// well as the incoming and outgoing routes' [Hero] descendants' widgets and
217 /// layout.
218 ///
219 /// When both the source and destination [Hero]es provide a [flightShuttleBuilder],
220 /// the destination's [flightShuttleBuilder] takes precedence.
221 ///
222 /// If none is provided, the destination route's Hero child is shown in-flight
223 /// by default.
224 ///
225 /// ## Limitations
226 ///
227 /// If a widget built by [flightShuttleBuilder] takes part in a [Navigator]
228 /// push transition, that widget or its descendants must not have any
229 /// [GlobalKey] that is used in the source Hero's descendant widgets. That is
230 /// because both subtrees will be included in the widget tree during the Hero
231 /// flight animation, and [GlobalKey]s must be unique across the entire widget
232 /// tree.
233 ///
234 /// If the said [GlobalKey] is essential to your application, consider providing
235 /// a custom [placeholderBuilder] for the source Hero, to avoid the [GlobalKey]
236 /// collision, such as a builder that builds an empty [SizedBox], keeping the
237 /// Hero [child]'s original size.
238 final HeroFlightShuttleBuilder? flightShuttleBuilder;
239
240 /// Placeholder widget left in place as the Hero's [child] once the flight takes
241 /// off.
242 ///
243 /// By default the placeholder widget is an empty [SizedBox] keeping the Hero
244 /// child's original size, unless this Hero is a source Hero of a [Navigator]
245 /// push transition, in which case [child] will be a descendant of the placeholder
246 /// and will be kept [Offstage] during the Hero's flight.
247 final HeroPlaceholderBuilder? placeholderBuilder;
248
249 /// Whether to perform the hero transition if the [PageRoute] transition was
250 /// triggered by a user gesture, such as a back swipe on iOS.
251 ///
252 /// If [Hero]es with the same [tag] on both the from and the to routes have
253 /// [transitionOnUserGestures] set to true, a back swipe gesture will
254 /// trigger the same hero animation as a programmatically triggered push or
255 /// pop.
256 ///
257 /// The route being popped to or the bottom route must also have
258 /// [PageRoute.maintainState] set to true for a gesture triggered hero
259 /// transition to work.
260 ///
261 /// Defaults to false.
262 final bool transitionOnUserGestures;
263
264 // Returns a map of all of the heroes in `context` indexed by hero tag that
265 // should be considered for animation when `navigator` transitions from one
266 // PageRoute to another.
267 static Map<Object, _HeroState> _allHeroesFor(
268 BuildContext context,
269 bool isUserGestureTransition,
270 NavigatorState navigator,
271 ) {
272 final Map<Object, _HeroState> result = <Object, _HeroState>{};
273
274 void inviteHero(StatefulElement hero, Object tag) {
275 assert(() {
276 if (result.containsKey(tag)) {
277 throw FlutterError.fromParts(<DiagnosticsNode>[
278 ErrorSummary('There are multiple heroes that share the same tag within a subtree.'),
279 ErrorDescription(
280 'Within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), '
281 'each Hero must have a unique non-null tag.\n'
282 'In this case, multiple heroes had the following tag: $tag',
283 ),
284 DiagnosticsProperty<StatefulElement>('Here is the subtree for one of the offending heroes', hero, linePrefix: '# ', style: DiagnosticsTreeStyle.dense),
285 ]);
286 }
287 return true;
288 }());
289 final Hero heroWidget = hero.widget as Hero;
290 final _HeroState heroState = hero.state as _HeroState;
291 if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) {
292 result[tag] = heroState;
293 } else {
294 // If transition is not allowed, we need to make sure hero is not hidden.
295 // A hero can be hidden previously due to hero transition.
296 heroState.endFlight();
297 }
298 }
299
300 void visitor(Element element) {
301 final Widget widget = element.widget;
302 if (widget is Hero) {
303 final StatefulElement hero = element as StatefulElement;
304 final Object tag = widget.tag;
305 if (Navigator.of(hero) == navigator) {
306 inviteHero(hero, tag);
307 } else {
308 // The nearest navigator to the Hero is not the Navigator that is
309 // currently transitioning from one route to another. This means
310 // the Hero is inside a nested Navigator and should only be
311 // considered for animation if it is part of the top-most route in
312 // that nested Navigator and if that route is also a PageRoute.
313 final ModalRoute<Object?>? heroRoute = ModalRoute.of(hero);
314 if (heroRoute != null && heroRoute is PageRoute && heroRoute.isCurrent) {
315 inviteHero(hero, tag);
316 }
317 }
318 } else if (widget is HeroMode && !widget.enabled) {
319 return;
320 }
321 element.visitChildren(visitor);
322 }
323
324 context.visitChildElements(visitor);
325 return result;
326 }
327
328 @override
329 State<Hero> createState() => _HeroState();
330
331 @override
332 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
333 super.debugFillProperties(properties);
334 properties.add(DiagnosticsProperty<Object>('tag', tag));
335 }
336}
337
338/// The [Hero] widget displays different content based on whether it is in an
339/// animated transition ("flight"), from/to another [Hero] with the same tag:
340/// * When [startFlight] is called, the real content of this [Hero] will be
341/// replaced by a "placeholder" widget.
342/// * When the flight ends, the "toHero"'s [endFlight] method must be called
343/// by the hero controller, so the real content of that [Hero] becomes
344/// visible again when the animation completes.
345class _HeroState extends State<Hero> {
346 final GlobalKey _key = GlobalKey();
347 Size? _placeholderSize;
348 // Whether the placeholder widget should wrap the hero's child widget as its
349 // own child, when `_placeholderSize` is non-null (i.e. the hero is currently
350 // in its flight animation). See `startFlight`.
351 bool _shouldIncludeChild = true;
352
353 // The `shouldIncludeChildInPlaceholder` flag dictates if the child widget of
354 // this hero should be included in the placeholder widget as a descendant.
355 //
356 // When a new hero flight animation takes place, a placeholder widget
357 // needs to be built to replace the original hero widget. When
358 // `shouldIncludeChildInPlaceholder` is set to true and `widget.placeholderBuilder`
359 // is null, the placeholder widget will include the original hero's child
360 // widget as a descendant, allowing the original element tree to be preserved.
361 //
362 // It is typically set to true for the *from* hero in a push transition,
363 // and false otherwise.
364 void startFlight({ bool shouldIncludedChildInPlaceholder = false }) {
365 _shouldIncludeChild = shouldIncludedChildInPlaceholder;
366 assert(mounted);
367 final RenderBox box = context.findRenderObject()! as RenderBox;
368 assert(box.hasSize);
369 setState(() {
370 _placeholderSize = box.size;
371 });
372 }
373
374 // When `keepPlaceholder` is true, the placeholder will continue to be shown
375 // after the flight ends. Otherwise the child of the Hero will become visible
376 // and its TickerMode will be re-enabled.
377 //
378 // This method can be safely called even when this [Hero] is currently not in
379 // a flight.
380 void endFlight({ bool keepPlaceholder = false }) {
381 if (keepPlaceholder || _placeholderSize == null) {
382 return;
383 }
384
385 _placeholderSize = null;
386 if (mounted) {
387 // Tell the widget to rebuild if it's mounted. _placeholderSize has already
388 // been updated.
389 setState(() {});
390 }
391 }
392
393 @override
394 Widget build(BuildContext context) {
395 assert(
396 context.findAncestorWidgetOfExactType<Hero>() == null,
397 'A Hero widget cannot be the descendant of another Hero widget.',
398 );
399
400 final bool showPlaceholder = _placeholderSize != null;
401
402 if (showPlaceholder && widget.placeholderBuilder != null) {
403 return widget.placeholderBuilder!(context, _placeholderSize!, widget.child);
404 }
405
406 if (showPlaceholder && !_shouldIncludeChild) {
407 return SizedBox(
408 width: _placeholderSize!.width,
409 height: _placeholderSize!.height,
410 );
411 }
412
413 return SizedBox(
414 width: _placeholderSize?.width,
415 height: _placeholderSize?.height,
416 child: Offstage(
417 offstage: showPlaceholder,
418 child: TickerMode(
419 enabled: !showPlaceholder,
420 child: KeyedSubtree(key: _key, child: widget.child),
421 ),
422 ),
423 );
424 }
425}
426
427// Everything known about a hero flight that's to be started or diverted.
428@immutable
429class _HeroFlightManifest {
430 _HeroFlightManifest({
431 required this.type,
432 required this.overlay,
433 required this.navigatorSize,
434 required this.fromRoute,
435 required this.toRoute,
436 required this.fromHero,
437 required this.toHero,
438 required this.createRectTween,
439 required this.shuttleBuilder,
440 required this.isUserGestureTransition,
441 required this.isDiverted,
442 }) : assert(fromHero.widget.tag == toHero.widget.tag);
443
444 final HeroFlightDirection type;
445 final OverlayState overlay;
446 final Size navigatorSize;
447 final PageRoute<dynamic> fromRoute;
448 final PageRoute<dynamic> toRoute;
449 final _HeroState fromHero;
450 final _HeroState toHero;
451 final CreateRectTween? createRectTween;
452 final HeroFlightShuttleBuilder shuttleBuilder;
453 final bool isUserGestureTransition;
454 final bool isDiverted;
455
456 Object get tag => fromHero.widget.tag;
457
458 Animation<double> get animation {
459 return CurvedAnimation(
460 parent: (type == HeroFlightDirection.push) ? toRoute.animation! : fromRoute.animation!,
461 curve: Curves.fastOutSlowIn,
462 reverseCurve: isDiverted ? null : Curves.fastOutSlowIn.flipped,
463 );
464 }
465
466 Tween<Rect?> createHeroRectTween({ required Rect? begin, required Rect? end }) {
467 final CreateRectTween? createRectTween = toHero.widget.createRectTween ?? this.createRectTween;
468 return createRectTween?.call(begin, end) ?? RectTween(begin: begin, end: end);
469 }
470
471 // The bounding box for `context`'s render object, in `ancestorContext`'s
472 // render object's coordinate space.
473 static Rect _boundingBoxFor(BuildContext context, BuildContext? ancestorContext) {
474 assert(ancestorContext != null);
475 final RenderBox box = context.findRenderObject()! as RenderBox;
476 assert(box.hasSize && box.size.isFinite);
477 return MatrixUtils.transformRect(
478 box.getTransformTo(ancestorContext?.findRenderObject()),
479 Offset.zero & box.size,
480 );
481 }
482
483 /// The bounding box of [fromHero], in [fromRoute]'s coordinate space.
484 ///
485 /// This property should only be accessed in [_HeroFlight.start].
486 late final Rect fromHeroLocation = _boundingBoxFor(fromHero.context, fromRoute.subtreeContext);
487
488 /// The bounding box of [toHero], in [toRoute]'s coordinate space.
489 ///
490 /// This property should only be accessed in [_HeroFlight.start] or
491 /// [_HeroFlight.divert].
492 late final Rect toHeroLocation = _boundingBoxFor(toHero.context, toRoute.subtreeContext);
493
494 /// Whether this [_HeroFlightManifest] is valid and can be used to start or
495 /// divert a [_HeroFlight].
496 ///
497 /// When starting or diverting a [_HeroFlight] with a brand new
498 /// [_HeroFlightManifest], this flag must be checked to ensure the [RectTween]
499 /// the [_HeroFlightManifest] produces does not contain coordinates that have
500 /// [double.infinity] or [double.nan].
501 late final bool isValid = toHeroLocation.isFinite && (isDiverted || fromHeroLocation.isFinite);
502
503 @override
504 String toString() {
505 return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
506 'to route: ${toRoute.settings} with hero: $fromHero to $toHero)${isValid ? '' : ', INVALID'}';
507 }
508}
509
510// Builds the in-flight hero widget.
511class _HeroFlight {
512 _HeroFlight(this.onFlightEnded) {
513 _proxyAnimation = ProxyAnimation()..addStatusListener(_handleAnimationUpdate);
514 }
515
516 final _OnFlightEnded onFlightEnded;
517
518 late Tween<Rect?> heroRectTween;
519 Widget? shuttle;
520
521 Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
522 late ProxyAnimation _proxyAnimation;
523 // The manifest will be available once `start` is called, throughout the
524 // flight's lifecycle.
525 late _HeroFlightManifest manifest;
526 OverlayEntry? overlayEntry;
527 bool _aborted = false;
528
529 static final Animatable<double> _reverseTween = Tween<double>(begin: 1.0, end: 0.0);
530
531 // The OverlayEntry WidgetBuilder callback for the hero's overlay.
532 Widget _buildOverlay(BuildContext context) {
533 shuttle ??= manifest.shuttleBuilder(
534 context,
535 manifest.animation,
536 manifest.type,
537 manifest.fromHero.context,
538 manifest.toHero.context,
539 );
540 assert(shuttle != null);
541
542 return AnimatedBuilder(
543 animation: _proxyAnimation,
544 child: shuttle,
545 builder: (BuildContext context, Widget? child) {
546 final Rect rect = heroRectTween.evaluate(_proxyAnimation)!;
547 final RelativeRect offsets = RelativeRect.fromSize(rect, manifest.navigatorSize);
548 return Positioned(
549 top: offsets.top,
550 right: offsets.right,
551 bottom: offsets.bottom,
552 left: offsets.left,
553 child: IgnorePointer(
554 child: FadeTransition(
555 opacity: _heroOpacity,
556 child: child,
557 ),
558 ),
559 );
560 },
561 );
562 }
563
564 void _performAnimationUpdate(AnimationStatus status) {
565 if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
566 _proxyAnimation.parent = null;
567
568 assert(overlayEntry != null);
569 overlayEntry!.remove();
570 overlayEntry!.dispose();
571 overlayEntry = null;
572 // We want to keep the hero underneath the current page hidden. If
573 // [AnimationStatus.completed], toHero will be the one on top and we keep
574 // fromHero hidden. If [AnimationStatus.dismissed], the animation is
575 // triggered but canceled before it finishes. In this case, we keep toHero
576 // hidden instead.
577 manifest.fromHero.endFlight(keepPlaceholder: status == AnimationStatus.completed);
578 manifest.toHero.endFlight(keepPlaceholder: status == AnimationStatus.dismissed);
579 onFlightEnded(this);
580 _proxyAnimation.removeListener(onTick);
581 }
582 }
583
584 bool _scheduledPerformAnimationUpdate = false;
585 void _handleAnimationUpdate(AnimationStatus status) {
586 // The animation will not finish until the user lifts their finger, so we
587 // should suppress the status update if the gesture is in progress, and
588 // delay it until the finger is lifted.
589 if (manifest.fromRoute.navigator?.userGestureInProgress != true) {
590 _performAnimationUpdate(status);
591 return;
592 }
593
594 if (_scheduledPerformAnimationUpdate) {
595 return;
596 }
597
598 // The `navigator` must be non-null here, or the first if clause above would
599 // have returned from this method.
600 final NavigatorState navigator = manifest.fromRoute.navigator!;
601
602 void delayedPerformAnimationUpdate() {
603 assert(!navigator.userGestureInProgress);
604 assert(_scheduledPerformAnimationUpdate);
605 _scheduledPerformAnimationUpdate = false;
606 navigator.userGestureInProgressNotifier.removeListener(delayedPerformAnimationUpdate);
607 _performAnimationUpdate(_proxyAnimation.status);
608 }
609 assert(navigator.userGestureInProgress);
610 _scheduledPerformAnimationUpdate = true;
611 navigator.userGestureInProgressNotifier.addListener(delayedPerformAnimationUpdate);
612 }
613
614 /// Releases resources.
615 @mustCallSuper
616 void dispose() {
617 if (overlayEntry != null) {
618 overlayEntry!.remove();
619 overlayEntry!.dispose();
620 overlayEntry = null;
621 _proxyAnimation.parent = null;
622 _proxyAnimation.removeListener(onTick);
623 _proxyAnimation.removeStatusListener(_handleAnimationUpdate);
624 }
625 }
626
627 void onTick() {
628 final RenderBox? toHeroBox = (!_aborted && manifest.toHero.mounted)
629 ? manifest.toHero.context.findRenderObject() as RenderBox?
630 : null;
631 // Try to find the new origin of the toHero, if the flight isn't aborted.
632 final Offset? toHeroOrigin = toHeroBox != null && toHeroBox.attached && toHeroBox.hasSize
633 ? toHeroBox.localToGlobal(Offset.zero, ancestor: manifest.toRoute.subtreeContext?.findRenderObject() as RenderBox?)
634 : null;
635
636 if (toHeroOrigin != null && toHeroOrigin.isFinite) {
637 // If the new origin of toHero is available and also paintable, try to
638 // update heroRectTween with it.
639 if (toHeroOrigin != heroRectTween.end!.topLeft) {
640 final Rect heroRectEnd = toHeroOrigin & heroRectTween.end!.size;
641 heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.begin, end: heroRectEnd);
642 }
643 } else if (_heroOpacity.isCompleted) {
644 // The toHero no longer exists or it's no longer the flight's destination.
645 // Continue flying while fading out.
646 _heroOpacity = _proxyAnimation.drive(
647 _reverseTween.chain(CurveTween(curve: Interval(_proxyAnimation.value, 1.0))),
648 );
649 }
650 // Update _aborted for the next animation tick.
651 _aborted = toHeroOrigin == null || !toHeroOrigin.isFinite;
652 }
653
654 // The simple case: we're either starting a push or a pop animation.
655 void start(_HeroFlightManifest initialManifest) {
656 assert(!_aborted);
657 assert(() {
658 final Animation<double> initial = initialManifest.animation;
659 final HeroFlightDirection type = initialManifest.type;
660 switch (type) {
661 case HeroFlightDirection.pop:
662 return initial.value == 1.0 && initialManifest.isUserGestureTransition
663 // During user gesture transitions, the animation controller isn't
664 // driving the reverse transition, but should still be in a previously
665 // completed stage with the initial value at 1.0.
666 ? initial.status == AnimationStatus.completed
667 : initial.status == AnimationStatus.reverse;
668 case HeroFlightDirection.push:
669 return initial.value == 0.0 && initial.status == AnimationStatus.forward;
670 }
671 }());
672
673 manifest = initialManifest;
674
675 final bool shouldIncludeChildInPlaceholder;
676 switch (manifest.type) {
677 case HeroFlightDirection.pop:
678 _proxyAnimation.parent = ReverseAnimation(manifest.animation);
679 shouldIncludeChildInPlaceholder = false;
680 case HeroFlightDirection.push:
681 _proxyAnimation.parent = manifest.animation;
682 shouldIncludeChildInPlaceholder = true;
683 }
684
685 heroRectTween = manifest.createHeroRectTween(begin: manifest.fromHeroLocation, end: manifest.toHeroLocation);
686 manifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: shouldIncludeChildInPlaceholder);
687 manifest.toHero.startFlight();
688 manifest.overlay.insert(overlayEntry = OverlayEntry(builder: _buildOverlay));
689 _proxyAnimation.addListener(onTick);
690 }
691
692 // While this flight's hero was in transition a push or a pop occurred for
693 // routes with the same hero. Redirect the in-flight hero to the new toRoute.
694 void divert(_HeroFlightManifest newManifest) {
695 assert(manifest.tag == newManifest.tag);
696 if (manifest.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) {
697 // A push flight was interrupted by a pop.
698 assert(newManifest.animation.status == AnimationStatus.reverse);
699 assert(manifest.fromHero == newManifest.toHero);
700 assert(manifest.toHero == newManifest.fromHero);
701 assert(manifest.fromRoute == newManifest.toRoute);
702 assert(manifest.toRoute == newManifest.fromRoute);
703
704 // The same heroRect tween is used in reverse, rather than creating
705 // a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
706 // That's because tweens like MaterialRectArcTween may create a different
707 // path for swapped begin and end parameters. We want the pop flight
708 // path to be the same (in reverse) as the push flight path.
709 _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
710 heroRectTween = ReverseTween<Rect?>(heroRectTween);
711 } else if (manifest.type == HeroFlightDirection.pop && newManifest.type == HeroFlightDirection.push) {
712 // A pop flight was interrupted by a push.
713 assert(newManifest.animation.status == AnimationStatus.forward);
714 assert(manifest.toHero == newManifest.fromHero);
715 assert(manifest.toRoute == newManifest.fromRoute);
716
717 _proxyAnimation.parent = newManifest.animation.drive(
718 Tween<double>(
719 begin: manifest.animation.value,
720 end: 1.0,
721 ),
722 );
723 if (manifest.fromHero != newManifest.toHero) {
724 manifest.fromHero.endFlight(keepPlaceholder: true);
725 newManifest.toHero.startFlight();
726 heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.end, end: newManifest.toHeroLocation);
727 } else {
728 // TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
729 heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.end, end: heroRectTween.begin);
730 }
731 } else {
732 // A push or a pop flight is heading to a new route, i.e.
733 // manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push ||
734 // manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.pop
735 assert(manifest.fromHero != newManifest.fromHero);
736 assert(manifest.toHero != newManifest.toHero);
737
738 heroRectTween = manifest.createHeroRectTween(
739 begin: heroRectTween.evaluate(_proxyAnimation),
740 end: newManifest.toHeroLocation,
741 );
742 shuttle = null;
743
744 if (newManifest.type == HeroFlightDirection.pop) {
745 _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
746 } else {
747 _proxyAnimation.parent = newManifest.animation;
748 }
749
750 manifest.fromHero.endFlight(keepPlaceholder: true);
751 manifest.toHero.endFlight(keepPlaceholder: true);
752
753 // Let the heroes in each of the routes rebuild with their placeholders.
754 newManifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push);
755 newManifest.toHero.startFlight();
756
757 // Let the transition overlay on top of the routes also rebuild since
758 // we cleared the old shuttle.
759 overlayEntry!.markNeedsBuild();
760 }
761
762 manifest = newManifest;
763 }
764
765 void abort() {
766 _aborted = true;
767 }
768
769 @override
770 String toString() {
771 final RouteSettings from = manifest.fromRoute.settings;
772 final RouteSettings to = manifest.toRoute.settings;
773 final Object tag = manifest.tag;
774 return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})';
775 }
776}
777
778/// A [Navigator] observer that manages [Hero] transitions.
779///
780/// An instance of [HeroController] should be used in [Navigator.observers].
781/// This is done automatically by [MaterialApp].
782class HeroController extends NavigatorObserver {
783 /// Creates a hero controller with the given [RectTween] constructor if any.
784 ///
785 /// The [createRectTween] argument is optional. If null, the controller uses a
786 /// linear [Tween<Rect>].
787 HeroController({ this.createRectTween }) {
788 // TODO(polina-c): stop duplicating code across disposables
789 // https://github.com/flutter/flutter/issues/137435
790 if (kFlutterMemoryAllocationsEnabled) {
791 FlutterMemoryAllocations.instance.dispatchObjectCreated(
792 library: 'package:flutter/widgets.dart',
793 className: '$HeroController',
794 object: this,
795 );
796 }
797 }
798
799 /// Used to create [RectTween]s that interpolate the position of heroes in flight.
800 ///
801 /// If null, the controller uses a linear [RectTween].
802 final CreateRectTween? createRectTween;
803
804 // All of the heroes that are currently in the overlay and in motion.
805 // Indexed by the hero tag.
806 final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
807
808 @override
809 void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
810 assert(navigator != null);
811 _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push, false);
812 }
813
814 @override
815 void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
816 assert(navigator != null);
817 // Don't trigger another flight when a pop is committed as a user gesture
818 // back swipe is snapped.
819 if (!navigator!.userGestureInProgress) {
820 _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, false);
821 }
822 }
823
824 @override
825 void didReplace({ Route<dynamic>? newRoute, Route<dynamic>? oldRoute }) {
826 assert(navigator != null);
827 if (newRoute?.isCurrent ?? false) {
828 // Only run hero animations if the top-most route got replaced.
829 _maybeStartHeroTransition(oldRoute, newRoute, HeroFlightDirection.push, false);
830 }
831 }
832
833 @override
834 void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) {
835 assert(navigator != null);
836 _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop, true);
837 }
838
839 @override
840 void didStopUserGesture() {
841 if (navigator!.userGestureInProgress) {
842 return;
843 }
844
845 // When the user gesture ends, if the user horizontal drag gesture initiated
846 // the flight (i.e. the back swipe) didn't move towards the pop direction at
847 // all, the animation will not play and thus the status update callback
848 // _handleAnimationUpdate will never be called when the gesture finishes. In
849 // this case the initiated flight needs to be manually invalidated.
850 bool isInvalidFlight(_HeroFlight flight) {
851 return flight.manifest.isUserGestureTransition
852 && flight.manifest.type == HeroFlightDirection.pop
853 && flight._proxyAnimation.isDismissed;
854 }
855
856 final List<_HeroFlight> invalidFlights = _flights.values
857 .where(isInvalidFlight)
858 .toList(growable: false);
859
860 // Treat these invalidated flights as dismissed. Calling _handleAnimationUpdate
861 // will also remove the flight from _flights.
862 for (final _HeroFlight flight in invalidFlights) {
863 flight._handleAnimationUpdate(AnimationStatus.dismissed);
864 }
865 }
866
867 // If we're transitioning between different page routes, start a hero transition
868 // after the toRoute has been laid out with its animation's value at 1.0.
869 void _maybeStartHeroTransition(
870 Route<dynamic>? fromRoute,
871 Route<dynamic>? toRoute,
872 HeroFlightDirection flightType,
873 bool isUserGestureTransition,
874 ) {
875 if (toRoute == fromRoute ||
876 toRoute is! PageRoute<dynamic> ||
877 fromRoute is! PageRoute<dynamic>) {
878 return;
879 }
880
881 final PageRoute<dynamic> from = fromRoute;
882 final PageRoute<dynamic> to = toRoute;
883
884 // A user gesture may have already completed the pop, or we might be the initial route
885 switch (flightType) {
886 case HeroFlightDirection.pop:
887 if (from.animation!.value == 0.0) {
888 return;
889 }
890 case HeroFlightDirection.push:
891 if (to.animation!.value == 1.0) {
892 return;
893 }
894 }
895
896 // For pop transitions driven by a user gesture: if the "to" page has
897 // maintainState = true, then the hero's final dimensions can be measured
898 // immediately because their page's layout is still valid.
899 if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) {
900 _startHeroTransition(from, to, flightType, isUserGestureTransition);
901 } else {
902 // Otherwise, delay measuring until the end of the next frame to allow
903 // the 'to' route to build and layout.
904
905 // Putting a route offstage changes its animation value to 1.0. Once this
906 // frame completes, we'll know where the heroes in the `to` route are
907 // going to end up, and the `to` route will go back onstage.
908 to.offstage = to.animation!.value == 0.0;
909
910 WidgetsBinding.instance.addPostFrameCallback((Duration value) {
911 if (from.navigator == null || to.navigator == null) {
912 return;
913 }
914 _startHeroTransition(from, to, flightType, isUserGestureTransition);
915 }, debugLabel: 'HeroController.startTransition');
916 }
917 }
918
919 // Find the matching pairs of heroes in from and to and either start or a new
920 // hero flight, or divert an existing one.
921 void _startHeroTransition(
922 PageRoute<dynamic> from,
923 PageRoute<dynamic> to,
924 HeroFlightDirection flightType,
925 bool isUserGestureTransition,
926 ) {
927 // If the `to` route was offstage, then we're implicitly restoring its
928 // animation value back to what it was before it was "moved" offstage.
929 to.offstage = false;
930
931 final NavigatorState? navigator = this.navigator;
932 final OverlayState? overlay = navigator?.overlay;
933 // If the navigator or the overlay was removed before this end-of-frame
934 // callback was called, then don't actually start a transition, and we don't
935 // have to worry about any Hero widget we might have hidden in a previous
936 // flight, or ongoing flights.
937 if (navigator == null || overlay == null) {
938 return;
939 }
940
941 final RenderObject? navigatorRenderObject = navigator.context.findRenderObject();
942
943 if (navigatorRenderObject is! RenderBox) {
944 assert(false, 'Navigator $navigator has an invalid RenderObject type ${navigatorRenderObject.runtimeType}.');
945 return;
946 }
947 assert(navigatorRenderObject.hasSize);
948
949 // At this point, the toHeroes may have been built and laid out for the first time.
950 //
951 // If `fromSubtreeContext` is null, call endFlight on all toHeroes, for good measure.
952 // If `toSubtreeContext` is null abort existingFlights.
953 final BuildContext? fromSubtreeContext = from.subtreeContext;
954 final Map<Object, _HeroState> fromHeroes = fromSubtreeContext != null
955 ? Hero._allHeroesFor(fromSubtreeContext, isUserGestureTransition, navigator)
956 : const <Object, _HeroState>{};
957 final BuildContext? toSubtreeContext = to.subtreeContext;
958 final Map<Object, _HeroState> toHeroes = toSubtreeContext != null
959 ? Hero._allHeroesFor(toSubtreeContext, isUserGestureTransition, navigator)
960 : const <Object, _HeroState>{};
961
962 for (final MapEntry<Object, _HeroState> fromHeroEntry in fromHeroes.entries) {
963 final Object tag = fromHeroEntry.key;
964 final _HeroState fromHero = fromHeroEntry.value;
965 final _HeroState? toHero = toHeroes[tag];
966 final _HeroFlight? existingFlight = _flights[tag];
967 final _HeroFlightManifest? manifest = toHero == null
968 ? null
969 : _HeroFlightManifest(
970 type: flightType,
971 overlay: overlay,
972 navigatorSize: navigatorRenderObject.size,
973 fromRoute: from,
974 toRoute: to,
975 fromHero: fromHero,
976 toHero: toHero,
977 createRectTween: createRectTween,
978 shuttleBuilder: toHero.widget.flightShuttleBuilder
979 ?? fromHero.widget.flightShuttleBuilder
980 ?? _defaultHeroFlightShuttleBuilder,
981 isUserGestureTransition: isUserGestureTransition,
982 isDiverted: existingFlight != null,
983 );
984
985 // Only proceed with a valid manifest. Otherwise abort the existing
986 // flight, and call endFlight when this for loop finishes.
987 if (manifest != null && manifest.isValid) {
988 toHeroes.remove(tag);
989 if (existingFlight != null) {
990 existingFlight.divert(manifest);
991 } else {
992 _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
993 }
994 } else {
995 existingFlight?.abort();
996 }
997 }
998
999 // The remaining entries in toHeroes are those failed to participate in a
1000 // new flight (for not having a valid manifest).
1001 //
1002 // This can happen in a route pop transition when a fromHero is no longer
1003 // mounted, or kept alive by the [KeepAlive] mechanism but no longer visible.
1004 // TODO(LongCatIsLooong): resume aborted flights: https://github.com/flutter/flutter/issues/72947
1005 for (final _HeroState toHero in toHeroes.values) {
1006 toHero.endFlight();
1007 }
1008 }
1009
1010 void _handleFlightEnded(_HeroFlight flight) {
1011 _flights.remove(flight.manifest.tag);
1012 }
1013
1014 Widget _defaultHeroFlightShuttleBuilder(
1015 BuildContext flightContext,
1016 Animation<double> animation,
1017 HeroFlightDirection flightDirection,
1018 BuildContext fromHeroContext,
1019 BuildContext toHeroContext,
1020 ) {
1021 final Hero toHero = toHeroContext.widget as Hero;
1022
1023 final MediaQueryData? toMediaQueryData = MediaQuery.maybeOf(toHeroContext);
1024 final MediaQueryData? fromMediaQueryData = MediaQuery.maybeOf(fromHeroContext);
1025
1026 if (toMediaQueryData == null || fromMediaQueryData == null) {
1027 return toHero.child;
1028 }
1029
1030 final EdgeInsets fromHeroPadding = fromMediaQueryData.padding;
1031 final EdgeInsets toHeroPadding = toMediaQueryData.padding;
1032
1033 return AnimatedBuilder(
1034 animation: animation,
1035 builder: (BuildContext context, Widget? child) {
1036 return MediaQuery(
1037 data: toMediaQueryData.copyWith(
1038 padding: (flightDirection == HeroFlightDirection.push)
1039 ? EdgeInsetsTween(
1040 begin: fromHeroPadding,
1041 end: toHeroPadding,
1042 ).evaluate(animation)
1043 : EdgeInsetsTween(
1044 begin: toHeroPadding,
1045 end: fromHeroPadding,
1046 ).evaluate(animation),
1047 ),
1048 child: toHero.child);
1049 },
1050 );
1051 }
1052
1053 /// Releases resources.
1054 @mustCallSuper
1055 void dispose() {
1056 // TODO(polina-c): stop duplicating code across disposables
1057 // https://github.com/flutter/flutter/issues/137435
1058 if (kFlutterMemoryAllocationsEnabled) {
1059 FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
1060 }
1061
1062 for (final _HeroFlight flight in _flights.values) {
1063 flight.dispose();
1064 }
1065 }
1066}
1067
1068/// Enables or disables [Hero]es in the widget subtree.
1069///
1070/// {@youtube 560 315 https://www.youtube.com/watch?v=AaIASk2u1C0}
1071///
1072/// When [enabled] is false, all [Hero] widgets in this subtree will not be
1073/// involved in hero animations.
1074///
1075/// When [enabled] is true (the default), [Hero] widgets may be involved in
1076/// hero animations, as usual.
1077class HeroMode extends StatelessWidget {
1078 /// Creates a widget that enables or disables [Hero]es.
1079 const HeroMode({
1080 super.key,
1081 required this.child,
1082 this.enabled = true,
1083 });
1084
1085 /// The subtree to place inside the [HeroMode].
1086 final Widget child;
1087
1088 /// Whether or not [Hero]es are enabled in this subtree.
1089 ///
1090 /// If this property is false, the [Hero]es in this subtree will not animate
1091 /// on route changes. Otherwise, they will animate as usual.
1092 ///
1093 /// Defaults to true.
1094 final bool enabled;
1095
1096 @override
1097 Widget build(BuildContext context) => child;
1098
1099 @override
1100 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
1101 super.debugFillProperties(properties);
1102 properties.add(FlagProperty('mode', value: enabled, ifTrue: 'enabled', ifFalse: 'disabled', showName: true));
1103 }
1104}
1105