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
5/// @docImport 'package:flutter/material.dart';
6library;
7
8import 'package:flutter/foundation.dart';
9import 'basic.dart';
10import 'binding.dart';
11import 'framework.dart';
12import 'implicit_animations.dart';
13import 'media_query.dart';
14import 'navigator.dart';
15import 'overlay.dart';
16import 'pages.dart';
17import 'routes.dart';
18import 'ticker_provider.dart' show TickerMode;
19import 'transitions.dart';
20
21/// Signature for a function that takes two [Rect] instances and returns a
22/// [RectTween] that transitions between them.
23///
24/// This is typically used with a [HeroController] to provide an animation for
25/// [Hero] positions that looks nicer than a linear movement. For example, see
26/// [MaterialRectArcTween].
27typedef CreateRectTween = Tween<Rect?> Function(Rect? begin, Rect? end);
28
29/// Signature for a function that builds a [Hero] placeholder widget given a
30/// child and a [Size].
31///
32/// The child can optionally be part of the returned widget tree. The returned
33/// widget should typically be constrained to [heroSize], if it doesn't do so
34/// implicitly.
35///
36/// See also:
37///
38/// * [TransitionBuilder], which is similar but only takes a [BuildContext]
39/// and a child widget.
40typedef HeroPlaceholderBuilder = Widget Function(BuildContext context, Size heroSize, Widget child);
41
42/// A function that lets [Hero]es self supply a [Widget] that is shown during the
43/// hero's flight from one route to another instead of default (which is to
44/// show the destination route's instance of the Hero).
45typedef HeroFlightShuttleBuilder =
46 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://docs.flutter.dev/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>(
285 'Here is the subtree for one of the offending heroes',
286 hero,
287 linePrefix: '# ',
288 style: DiagnosticsTreeStyle.dense,
289 ),
290 ]);
291 }
292 return true;
293 }());
294 final Hero heroWidget = hero.widget as Hero;
295 final _HeroState heroState = hero.state as _HeroState;
296 if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) {
297 result[tag] = heroState;
298 } else {
299 // If transition is not allowed, we need to make sure hero is not hidden.
300 // A hero can be hidden previously due to hero transition.
301 heroState.endFlight();
302 }
303 }
304
305 void visitor(Element element) {
306 final Widget widget = element.widget;
307 if (widget is Hero) {
308 final StatefulElement hero = element as StatefulElement;
309 final Object tag = widget.tag;
310 if (Navigator.of(hero) == navigator) {
311 inviteHero(hero, tag);
312 } else {
313 // The nearest navigator to the Hero is not the Navigator that is
314 // currently transitioning from one route to another. This means
315 // the Hero is inside a nested Navigator and should only be
316 // considered for animation if it is part of the top-most route in
317 // that nested Navigator and if that route is also a PageRoute.
318 final ModalRoute<Object?>? heroRoute = ModalRoute.of(hero);
319 if (heroRoute != null && heroRoute is PageRoute && heroRoute.isCurrent) {
320 inviteHero(hero, tag);
321 }
322 }
323 } else if (widget is HeroMode && !widget.enabled) {
324 return;
325 }
326 element.visitChildren(visitor);
327 }
328
329 context.visitChildElements(visitor);
330 return result;
331 }
332
333 @override
334 State<Hero> createState() => _HeroState();
335
336 @override
337 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
338 super.debugFillProperties(properties);
339 properties.add(DiagnosticsProperty<Object>('tag', tag));
340 }
341}
342
343/// The [Hero] widget displays different content based on whether it is in an
344/// animated transition ("flight"), from/to another [Hero] with the same tag:
345/// * When [startFlight] is called, the real content of this [Hero] will be
346/// replaced by a "placeholder" widget.
347/// * When the flight ends, the "toHero"'s [endFlight] method must be called
348/// by the hero controller, so the real content of that [Hero] becomes
349/// visible again when the animation completes.
350class _HeroState extends State<Hero> {
351 final GlobalKey _key = GlobalKey();
352 Size? _placeholderSize;
353 // Whether the placeholder widget should wrap the hero's child widget as its
354 // own child, when `_placeholderSize` is non-null (i.e. the hero is currently
355 // in its flight animation). See `startFlight`.
356 bool _shouldIncludeChild = true;
357
358 // The `shouldIncludeChildInPlaceholder` flag dictates if the child widget of
359 // this hero should be included in the placeholder widget as a descendant.
360 //
361 // When a new hero flight animation takes place, a placeholder widget
362 // needs to be built to replace the original hero widget. When
363 // `shouldIncludeChildInPlaceholder` is set to true and `widget.placeholderBuilder`
364 // is null, the placeholder widget will include the original hero's child
365 // widget as a descendant, allowing the original element tree to be preserved.
366 //
367 // It is typically set to true for the *from* hero in a push transition,
368 // and false otherwise.
369 void startFlight({bool shouldIncludedChildInPlaceholder = false}) {
370 _shouldIncludeChild = shouldIncludedChildInPlaceholder;
371 assert(mounted);
372 final RenderBox box = context.findRenderObject()! as RenderBox;
373 assert(box.hasSize);
374 setState(() {
375 _placeholderSize = box.size;
376 });
377 }
378
379 // When `keepPlaceholder` is true, the placeholder will continue to be shown
380 // after the flight ends. Otherwise the child of the Hero will become visible
381 // and its TickerMode will be re-enabled.
382 //
383 // This method can be safely called even when this [Hero] is currently not in
384 // a flight.
385 void endFlight({bool keepPlaceholder = false}) {
386 if (keepPlaceholder || _placeholderSize == null) {
387 return;
388 }
389
390 _placeholderSize = null;
391 if (mounted) {
392 // Tell the widget to rebuild if it's mounted. _placeholderSize has already
393 // been updated.
394 setState(() {});
395 }
396 }
397
398 @override
399 Widget build(BuildContext context) {
400 assert(
401 context.findAncestorWidgetOfExactType<Hero>() == null,
402 'A Hero widget cannot be the descendant of another Hero widget.',
403 );
404
405 final bool showPlaceholder = _placeholderSize != null;
406
407 if (showPlaceholder && widget.placeholderBuilder != null) {
408 return widget.placeholderBuilder!(context, _placeholderSize!, widget.child);
409 }
410
411 if (showPlaceholder && !_shouldIncludeChild) {
412 return SizedBox(width: _placeholderSize!.width, height: _placeholderSize!.height);
413 }
414
415 return SizedBox(
416 width: _placeholderSize?.width,
417 height: _placeholderSize?.height,
418 child: Offstage(
419 offstage: showPlaceholder,
420 child: TickerMode(
421 enabled: !showPlaceholder,
422 child: KeyedSubtree(key: _key, child: widget.child),
423 ),
424 ),
425 );
426 }
427}
428
429// Everything known about a hero flight that's to be started or diverted.
430class _HeroFlightManifest {
431 _HeroFlightManifest({
432 required this.type,
433 required this.overlay,
434 required this.navigatorSize,
435 required this.fromRoute,
436 required this.toRoute,
437 required this.fromHero,
438 required this.toHero,
439 required this.createRectTween,
440 required this.shuttleBuilder,
441 required this.isUserGestureTransition,
442 required this.isDiverted,
443 }) : assert(fromHero.widget.tag == toHero.widget.tag);
444
445 final HeroFlightDirection type;
446 final OverlayState overlay;
447 final Size navigatorSize;
448 final PageRoute<dynamic> fromRoute;
449 final PageRoute<dynamic> toRoute;
450 final _HeroState fromHero;
451 final _HeroState toHero;
452 final CreateRectTween? createRectTween;
453 final HeroFlightShuttleBuilder shuttleBuilder;
454 final bool isUserGestureTransition;
455 final bool isDiverted;
456
457 Object get tag => fromHero.widget.tag;
458
459 CurvedAnimation? _animation;
460
461 Animation<double> get animation {
462 return _animation ??= CurvedAnimation(
463 parent: (type == HeroFlightDirection.push) ? toRoute.animation! : fromRoute.animation!,
464 curve: Curves.fastOutSlowIn,
465 reverseCurve: isDiverted ? null : Curves.fastOutSlowIn.flipped,
466 );
467 }
468
469 Tween<Rect?> createHeroRectTween({required Rect? begin, required Rect? end}) {
470 final CreateRectTween? createRectTween = toHero.widget.createRectTween ?? this.createRectTween;
471 return createRectTween?.call(begin, end) ?? RectTween(begin: begin, end: end);
472 }
473
474 // The bounding box for `context`'s render object, in `ancestorContext`'s
475 // render object's coordinate space.
476 static Rect _boundingBoxFor(BuildContext context, BuildContext? ancestorContext) {
477 assert(ancestorContext != null);
478 final RenderBox box = context.findRenderObject()! as RenderBox;
479 assert(box.hasSize && box.size.isFinite);
480 return MatrixUtils.transformRect(
481 box.getTransformTo(ancestorContext?.findRenderObject()),
482 Offset.zero & box.size,
483 );
484 }
485
486 /// The bounding box of [fromHero], in [fromRoute]'s coordinate space.
487 ///
488 /// This property should only be accessed in [_HeroFlight.start].
489 late final Rect fromHeroLocation = _boundingBoxFor(fromHero.context, fromRoute.subtreeContext);
490
491 /// The bounding box of [toHero], in [toRoute]'s coordinate space.
492 ///
493 /// This property should only be accessed in [_HeroFlight.start] or
494 /// [_HeroFlight.divert].
495 late final Rect toHeroLocation = _boundingBoxFor(toHero.context, toRoute.subtreeContext);
496
497 /// Whether this [_HeroFlightManifest] is valid and can be used to start or
498 /// divert a [_HeroFlight].
499 ///
500 /// When starting or diverting a [_HeroFlight] with a brand new
501 /// [_HeroFlightManifest], this flag must be checked to ensure the [RectTween]
502 /// the [_HeroFlightManifest] produces does not contain coordinates that have
503 /// [double.infinity] or [double.nan].
504 late final bool isValid = toHeroLocation.isFinite && (isDiverted || fromHeroLocation.isFinite);
505
506 @override
507 String toString() {
508 return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
509 'to route: ${toRoute.settings} with hero: $fromHero to $toHero)${isValid ? '' : ', INVALID'}';
510 }
511
512 @mustCallSuper
513 void dispose() {
514 _animation?.dispose();
515 }
516}
517
518// Builds the in-flight hero widget.
519class _HeroFlight {
520 _HeroFlight(this.onFlightEnded) {
521 assert(debugMaybeDispatchCreated('widgets', '_HeroFlight', this));
522 _proxyAnimation = ProxyAnimation()..addStatusListener(_handleAnimationUpdate);
523 }
524
525 final _OnFlightEnded onFlightEnded;
526
527 late Tween<Rect?> heroRectTween;
528 Widget? shuttle;
529
530 Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
531 late ProxyAnimation _proxyAnimation;
532 // The manifest will be available once `start` is called, throughout the
533 // flight's lifecycle.
534 _HeroFlightManifest? _manifest;
535 _HeroFlightManifest get manifest => _manifest!;
536 set manifest(_HeroFlightManifest value) {
537 _manifest?.dispose();
538 _manifest = value;
539 }
540
541 OverlayEntry? overlayEntry;
542 bool _aborted = false;
543
544 static final Animatable<double> _reverseTween = Tween<double>(begin: 1.0, end: 0.0);
545
546 // The OverlayEntry WidgetBuilder callback for the hero's overlay.
547 Widget _buildOverlay(BuildContext context) {
548 shuttle ??= manifest.shuttleBuilder(
549 context,
550 manifest.animation,
551 manifest.type,
552 manifest.fromHero.context,
553 manifest.toHero.context,
554 );
555 assert(shuttle != null);
556
557 return AnimatedBuilder(
558 animation: _proxyAnimation,
559 child: shuttle,
560 builder: (BuildContext context, Widget? child) {
561 final Rect rect = heroRectTween.evaluate(_proxyAnimation)!;
562 final RelativeRect offsets = RelativeRect.fromSize(rect, manifest.navigatorSize);
563 return Positioned(
564 top: offsets.top,
565 right: offsets.right,
566 bottom: offsets.bottom,
567 left: offsets.left,
568 child: IgnorePointer(
569 child: FadeTransition(opacity: _heroOpacity, child: child),
570 ),
571 );
572 },
573 );
574 }
575
576 void _performAnimationUpdate(AnimationStatus status) {
577 if (!status.isAnimating) {
578 _proxyAnimation.parent = null;
579
580 assert(overlayEntry != null);
581 overlayEntry!.remove();
582 overlayEntry!.dispose();
583 overlayEntry = null;
584 // We want to keep the hero underneath the current page hidden. If
585 // [AnimationStatus.completed], toHero will be the one on top and we keep
586 // fromHero hidden. If [AnimationStatus.dismissed], the animation is
587 // triggered but canceled before it finishes. In this case, we keep toHero
588 // hidden instead.
589 manifest.fromHero.endFlight(keepPlaceholder: status.isCompleted);
590 manifest.toHero.endFlight(keepPlaceholder: status.isDismissed);
591 onFlightEnded(this);
592 _proxyAnimation.removeListener(onTick);
593 }
594 }
595
596 bool _scheduledPerformAnimationUpdate = false;
597 void _handleAnimationUpdate(AnimationStatus status) {
598 // The animation will not finish until the user lifts their finger, so we
599 // should suppress the status update if the gesture is in progress, and
600 // delay it until the finger is lifted.
601 if (manifest.fromRoute.navigator?.userGestureInProgress != true) {
602 _performAnimationUpdate(status);
603 return;
604 }
605
606 if (_scheduledPerformAnimationUpdate) {
607 return;
608 }
609
610 // The `navigator` must be non-null here, or the first if clause above would
611 // have returned from this method.
612 final NavigatorState navigator = manifest.fromRoute.navigator!;
613
614 void delayedPerformAnimationUpdate() {
615 assert(!navigator.userGestureInProgress);
616 assert(_scheduledPerformAnimationUpdate);
617 _scheduledPerformAnimationUpdate = false;
618 navigator.userGestureInProgressNotifier.removeListener(delayedPerformAnimationUpdate);
619 _performAnimationUpdate(_proxyAnimation.status);
620 }
621
622 assert(navigator.userGestureInProgress);
623 _scheduledPerformAnimationUpdate = true;
624 navigator.userGestureInProgressNotifier.addListener(delayedPerformAnimationUpdate);
625 }
626
627 /// Releases resources.
628 @mustCallSuper
629 void dispose() {
630 assert(debugMaybeDispatchDisposed(this));
631 if (overlayEntry != null) {
632 overlayEntry!.remove();
633 overlayEntry!.dispose();
634 overlayEntry = null;
635 _proxyAnimation.parent = null;
636 _proxyAnimation.removeListener(onTick);
637 _proxyAnimation.removeStatusListener(_handleAnimationUpdate);
638 }
639 _manifest?.dispose();
640 }
641
642 void onTick() {
643 final RenderBox? toHeroBox = (!_aborted && manifest.toHero.mounted)
644 ? manifest.toHero.context.findRenderObject() as RenderBox?
645 : null;
646 // Try to find the new origin of the toHero, if the flight isn't aborted.
647 final Offset? toHeroOrigin = toHeroBox != null && toHeroBox.attached && toHeroBox.hasSize
648 ? toHeroBox.localToGlobal(
649 Offset.zero,
650 ancestor: manifest.toRoute.subtreeContext?.findRenderObject() as RenderBox?,
651 )
652 : null;
653
654 if (toHeroOrigin != null && toHeroOrigin.isFinite) {
655 // If the new origin of toHero is available and also paintable, try to
656 // update heroRectTween with it.
657 if (toHeroOrigin != heroRectTween.end!.topLeft) {
658 final Rect heroRectEnd = toHeroOrigin & heroRectTween.end!.size;
659 heroRectTween = manifest.createHeroRectTween(begin: heroRectTween.begin, end: heroRectEnd);
660 }
661 } else if (_heroOpacity.isCompleted) {
662 // The toHero no longer exists or it's no longer the flight's destination.
663 // Continue flying while fading out.
664 _heroOpacity = _proxyAnimation.drive(
665 _reverseTween.chain(CurveTween(curve: Interval(_proxyAnimation.value, 1.0))),
666 );
667 }
668 // Update _aborted for the next animation tick.
669 _aborted = toHeroOrigin == null || !toHeroOrigin.isFinite;
670 }
671
672 // The simple case: we're either starting a push or a pop animation.
673 void start(_HeroFlightManifest initialManifest) {
674 assert(!_aborted);
675 assert(() {
676 final Animation<double> initial = initialManifest.animation;
677 final HeroFlightDirection type = initialManifest.type;
678 switch (type) {
679 case HeroFlightDirection.pop:
680 return initialManifest.isUserGestureTransition
681 // During user gesture transitions, the animation controller isn't
682 // driving the reverse transition, so the status is not important.
683 ||
684 initial.status == AnimationStatus.reverse;
685 case HeroFlightDirection.push:
686 return initial.value == 0.0 && initial.status == AnimationStatus.forward;
687 }
688 }());
689
690 manifest = initialManifest;
691
692 final bool shouldIncludeChildInPlaceholder;
693 switch (manifest.type) {
694 case HeroFlightDirection.pop:
695 _proxyAnimation.parent = ReverseAnimation(manifest.animation);
696 shouldIncludeChildInPlaceholder = false;
697 case HeroFlightDirection.push:
698 _proxyAnimation.parent = manifest.animation;
699 shouldIncludeChildInPlaceholder = true;
700 }
701
702 heroRectTween = manifest.createHeroRectTween(
703 begin: manifest.fromHeroLocation,
704 end: manifest.toHeroLocation,
705 );
706 manifest.fromHero.startFlight(
707 shouldIncludedChildInPlaceholder: shouldIncludeChildInPlaceholder,
708 );
709 manifest.toHero.startFlight();
710 manifest.overlay.insert(overlayEntry = OverlayEntry(builder: _buildOverlay));
711 _proxyAnimation.addListener(onTick);
712 }
713
714 // While this flight's hero was in transition a push or a pop occurred for
715 // routes with the same hero. Redirect the in-flight hero to the new toRoute.
716 void divert(_HeroFlightManifest newManifest) {
717 assert(manifest.tag == newManifest.tag);
718 if (manifest.type == HeroFlightDirection.push && newManifest.type == HeroFlightDirection.pop) {
719 // A push flight was interrupted by a pop.
720 assert(newManifest.animation.status == AnimationStatus.reverse);
721 assert(manifest.fromHero == newManifest.toHero);
722 assert(manifest.toHero == newManifest.fromHero);
723 assert(manifest.fromRoute == newManifest.toRoute);
724 assert(manifest.toRoute == newManifest.fromRoute);
725
726 // The same heroRect tween is used in reverse, rather than creating
727 // a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
728 // That's because tweens like MaterialRectArcTween may create a different
729 // path for swapped begin and end parameters. We want the pop flight
730 // path to be the same (in reverse) as the push flight path.
731 _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
732 heroRectTween = ReverseTween<Rect?>(heroRectTween);
733 } else if (manifest.type == HeroFlightDirection.pop &&
734 newManifest.type == HeroFlightDirection.push) {
735 // A pop flight was interrupted by a push.
736 assert(newManifest.animation.status == AnimationStatus.forward);
737 assert(manifest.toHero == newManifest.fromHero);
738 assert(manifest.toRoute == newManifest.fromRoute);
739
740 _proxyAnimation.parent = newManifest.animation.drive(
741 Tween<double>(begin: manifest.animation.value, end: 1.0),
742 );
743 if (manifest.fromHero != newManifest.toHero) {
744 manifest.fromHero.endFlight(keepPlaceholder: true);
745 newManifest.toHero.startFlight();
746 heroRectTween = manifest.createHeroRectTween(
747 begin: heroRectTween.end,
748 end: newManifest.toHeroLocation,
749 );
750 } else {
751 // TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
752 heroRectTween = manifest.createHeroRectTween(
753 begin: heroRectTween.end,
754 end: heroRectTween.begin,
755 );
756 }
757 } else {
758 // A push or a pop flight is heading to a new route, i.e.
759 // manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push ||
760 // manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.pop
761 assert(manifest.fromHero != newManifest.fromHero);
762 assert(manifest.toHero != newManifest.toHero);
763
764 heroRectTween = manifest.createHeroRectTween(
765 begin: heroRectTween.evaluate(_proxyAnimation),
766 end: newManifest.toHeroLocation,
767 );
768 shuttle = null;
769
770 if (newManifest.type == HeroFlightDirection.pop) {
771 _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
772 } else {
773 _proxyAnimation.parent = newManifest.animation;
774 }
775
776 manifest.fromHero.endFlight(keepPlaceholder: true);
777 manifest.toHero.endFlight(keepPlaceholder: true);
778
779 // Let the heroes in each of the routes rebuild with their placeholders.
780 newManifest.fromHero.startFlight(
781 shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push,
782 );
783 newManifest.toHero.startFlight();
784
785 // Let the transition overlay on top of the routes also rebuild since
786 // we cleared the old shuttle.
787 overlayEntry!.markNeedsBuild();
788 }
789
790 manifest = newManifest;
791 }
792
793 void abort() {
794 _aborted = true;
795 }
796
797 @override
798 String toString() {
799 final RouteSettings from = manifest.fromRoute.settings;
800 final RouteSettings to = manifest.toRoute.settings;
801 final Object tag = manifest.tag;
802 return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})';
803 }
804}
805
806/// A [Navigator] observer that manages [Hero] transitions.
807///
808/// An instance of [HeroController] should be used in [Navigator.observers].
809/// This is done automatically by [MaterialApp].
810class HeroController extends NavigatorObserver {
811 /// Creates a hero controller with the given [RectTween] constructor if any.
812 ///
813 /// The [createRectTween] argument is optional. If null, the controller uses a
814 /// linear [Tween<Rect>].
815 HeroController({this.createRectTween}) {
816 assert(debugMaybeDispatchCreated('widgets', 'HeroController', this));
817 }
818
819 /// Used to create [RectTween]s that interpolate the position of heroes in flight.
820 ///
821 /// If null, the controller uses a linear [RectTween].
822 final CreateRectTween? createRectTween;
823
824 // All of the heroes that are currently in the overlay and in motion.
825 // Indexed by the hero tag.
826 final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
827
828 @override
829 void didChangeTop(Route<dynamic> topRoute, Route<dynamic>? previousTopRoute) {
830 assert(topRoute.isCurrent);
831 assert(navigator != null);
832 if (previousTopRoute == null) {
833 return;
834 }
835 // Don't trigger another flight when a pop is committed as a user gesture
836 // back swipe is snapped.
837 if (!navigator!.userGestureInProgress) {
838 _maybeStartHeroTransition(
839 fromRoute: previousTopRoute,
840 toRoute: topRoute,
841 isUserGestureTransition: false,
842 );
843 }
844 }
845
846 @override
847 void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) {
848 assert(navigator != null);
849 _maybeStartHeroTransition(
850 fromRoute: route,
851 toRoute: previousRoute,
852 isUserGestureTransition: true,
853 );
854 }
855
856 @override
857 void didStopUserGesture() {
858 if (navigator!.userGestureInProgress) {
859 return;
860 }
861
862 // When the user gesture ends, if the user horizontal drag gesture initiated
863 // the flight (i.e. the back swipe) didn't move towards the pop direction at
864 // all, the animation will not play and thus the status update callback
865 // _handleAnimationUpdate will never be called when the gesture finishes. In
866 // this case the initiated flight needs to be manually invalidated.
867 bool isInvalidFlight(_HeroFlight flight) {
868 return flight.manifest.isUserGestureTransition &&
869 flight.manifest.type == HeroFlightDirection.pop &&
870 flight._proxyAnimation.isDismissed;
871 }
872
873 final List<_HeroFlight> invalidFlights = _flights.values
874 .where(isInvalidFlight)
875 .toList(growable: false);
876
877 // Treat these invalidated flights as dismissed. Calling _handleAnimationUpdate
878 // will also remove the flight from _flights.
879 for (final _HeroFlight flight in invalidFlights) {
880 flight._handleAnimationUpdate(AnimationStatus.dismissed);
881 }
882 }
883
884 // If we're transitioning between different page routes, start a hero transition
885 // after the toRoute has been laid out with its animation's value at 1.0.
886 void _maybeStartHeroTransition({
887 required Route<dynamic>? fromRoute,
888 required Route<dynamic>? toRoute,
889 required bool isUserGestureTransition,
890 }) {
891 if (toRoute == fromRoute ||
892 toRoute is! PageRoute<dynamic> ||
893 fromRoute is! PageRoute<dynamic>) {
894 return;
895 }
896 final Animation<double> newRouteAnimation = toRoute.animation!;
897 final Animation<double> oldRouteAnimation = fromRoute.animation!;
898 final HeroFlightDirection? flightType;
899 switch ((isUserGestureTransition, oldRouteAnimation.status, newRouteAnimation.status)) {
900 case (true, _, _):
901 case (_, AnimationStatus.reverse, _):
902 flightType = HeroFlightDirection.pop;
903 case (_, _, AnimationStatus.forward):
904 flightType = HeroFlightDirection.push;
905 default:
906 flightType = null;
907 }
908
909 // A user gesture may have already completed the pop, or we might be the initial route
910 if (flightType != null) {
911 switch (flightType) {
912 case HeroFlightDirection.pop:
913 if (fromRoute.animation!.value == 0.0) {
914 return;
915 }
916 case HeroFlightDirection.push:
917 if (toRoute.animation!.value == 1.0) {
918 return;
919 }
920 }
921 }
922
923 // For pop transitions driven by a user gesture: if the "to" page has
924 // maintainState = true, then the hero's final dimensions can be measured
925 // immediately because their page's layout is still valid. Unless due to directly
926 // adding routes to the pages stack causing the route to never get laid out.
927 final RenderBox? fromRouteRenderBox = toRoute.subtreeContext?.findRenderObject() as RenderBox?;
928 final bool hasValidSize =
929 (fromRouteRenderBox?.hasSize ?? false) && fromRouteRenderBox!.size.isFinite;
930 if (isUserGestureTransition &&
931 flightType == HeroFlightDirection.pop &&
932 toRoute.maintainState &&
933 hasValidSize) {
934 _startHeroTransition(fromRoute, toRoute, flightType, isUserGestureTransition);
935 } else {
936 // Otherwise, delay measuring until the end of the next frame to allow
937 // the 'to' route to build and layout.
938
939 // Putting a route offstage changes its animation value to 1.0. Once this
940 // frame completes, we'll know where the heroes in the `to` route are
941 // going to end up, and the `to` route will go back onstage.
942 toRoute.offstage = toRoute.animation!.value == 0.0;
943 WidgetsBinding.instance.addPostFrameCallback((Duration value) {
944 if (fromRoute.navigator == null || toRoute.navigator == null) {
945 return;
946 }
947 _startHeroTransition(fromRoute, toRoute, flightType, isUserGestureTransition);
948 }, debugLabel: 'HeroController.startTransition');
949 }
950 }
951
952 // Find the matching pairs of heroes in from and to and either start or a new
953 // hero flight, or divert an existing one.
954 void _startHeroTransition(
955 PageRoute<dynamic> from,
956 PageRoute<dynamic> to,
957 HeroFlightDirection? flightType,
958 bool isUserGestureTransition,
959 ) {
960 // If the `to` route was offstage, then we're implicitly restoring its
961 // animation value back to what it was before it was "moved" offstage.
962 to.offstage = false;
963
964 final NavigatorState? navigator = this.navigator;
965 final OverlayState? overlay = navigator?.overlay;
966 // If the navigator or the overlay was removed before this end-of-frame
967 // callback was called, then don't actually start a transition, and we don't
968 // have to worry about any Hero widget we might have hidden in a previous
969 // flight, or ongoing flights.
970 if (navigator == null || overlay == null) {
971 return;
972 }
973
974 final RenderObject? navigatorRenderObject = navigator.context.findRenderObject();
975
976 if (navigatorRenderObject is! RenderBox) {
977 assert(
978 false,
979 'Navigator $navigator has an invalid RenderObject type ${navigatorRenderObject.runtimeType}.',
980 );
981 return;
982 }
983 assert(navigatorRenderObject.hasSize);
984
985 // At this point, the toHeroes may have been built and laid out for the first time.
986 //
987 // If `fromSubtreeContext` is null, call endFlight on all toHeroes, for good measure.
988 // If `toSubtreeContext` is null abort existingFlights.
989 final BuildContext? fromSubtreeContext = from.subtreeContext;
990 final Map<Object, _HeroState> fromHeroes = fromSubtreeContext != null
991 ? Hero._allHeroesFor(fromSubtreeContext, isUserGestureTransition, navigator)
992 : const <Object, _HeroState>{};
993 final BuildContext? toSubtreeContext = to.subtreeContext;
994 final Map<Object, _HeroState> toHeroes = toSubtreeContext != null
995 ? Hero._allHeroesFor(toSubtreeContext, isUserGestureTransition, navigator)
996 : const <Object, _HeroState>{};
997
998 for (final MapEntry<Object, _HeroState> fromHeroEntry in fromHeroes.entries) {
999 final Object tag = fromHeroEntry.key;
1000 final _HeroState fromHero = fromHeroEntry.value;
1001 final _HeroState? toHero = toHeroes[tag];
1002 final _HeroFlight? existingFlight = _flights[tag];
1003 final _HeroFlightManifest? manifest = toHero == null || flightType == null
1004 ? null
1005 : _HeroFlightManifest(
1006 type: flightType,
1007 overlay: overlay,
1008 navigatorSize: navigatorRenderObject.size,
1009 fromRoute: from,
1010 toRoute: to,
1011 fromHero: fromHero,
1012 toHero: toHero,
1013 createRectTween: createRectTween,
1014 shuttleBuilder:
1015 toHero.widget.flightShuttleBuilder ??
1016 fromHero.widget.flightShuttleBuilder ??
1017 _defaultHeroFlightShuttleBuilder,
1018 isUserGestureTransition: isUserGestureTransition,
1019 isDiverted: existingFlight != null,
1020 );
1021
1022 // Only proceed with a valid manifest. Otherwise abort the existing
1023 // flight, and call endFlight when this for loop finishes.
1024 if (manifest != null && manifest.isValid) {
1025 toHeroes.remove(tag);
1026 if (existingFlight != null) {
1027 existingFlight.divert(manifest);
1028 } else {
1029 _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
1030 }
1031 } else {
1032 existingFlight?.abort();
1033 }
1034 }
1035
1036 // The remaining entries in toHeroes are those failed to participate in a
1037 // new flight (for not having a valid manifest).
1038 //
1039 // This can happen in a route pop transition when a fromHero is no longer
1040 // mounted, or kept alive by the [KeepAlive] mechanism but no longer visible.
1041 // TODO(LongCatIsLooong): resume aborted flights: https://github.com/flutter/flutter/issues/72947
1042 for (final _HeroState toHero in toHeroes.values) {
1043 toHero.endFlight();
1044 }
1045 }
1046
1047 void _handleFlightEnded(_HeroFlight flight) {
1048 _flights.remove(flight.manifest.tag)?.dispose();
1049 }
1050
1051 Widget _defaultHeroFlightShuttleBuilder(
1052 BuildContext flightContext,
1053 Animation<double> animation,
1054 HeroFlightDirection flightDirection,
1055 BuildContext fromHeroContext,
1056 BuildContext toHeroContext,
1057 ) {
1058 final Hero toHero = toHeroContext.widget as Hero;
1059
1060 final MediaQueryData? toMediaQueryData = MediaQuery.maybeOf(toHeroContext);
1061 final MediaQueryData? fromMediaQueryData = MediaQuery.maybeOf(fromHeroContext);
1062
1063 if (toMediaQueryData == null || fromMediaQueryData == null) {
1064 return toHero.child;
1065 }
1066
1067 final EdgeInsets fromHeroPadding = fromMediaQueryData.padding;
1068 final EdgeInsets toHeroPadding = toMediaQueryData.padding;
1069
1070 return AnimatedBuilder(
1071 animation: animation,
1072 builder: (BuildContext context, Widget? child) {
1073 return MediaQuery(
1074 data: toMediaQueryData.copyWith(
1075 padding: (flightDirection == HeroFlightDirection.push)
1076 ? EdgeInsetsTween(begin: fromHeroPadding, end: toHeroPadding).evaluate(animation)
1077 : EdgeInsetsTween(begin: toHeroPadding, end: fromHeroPadding).evaluate(animation),
1078 ),
1079 child: toHero.child,
1080 );
1081 },
1082 );
1083 }
1084
1085 /// Releases resources.
1086 @mustCallSuper
1087 void dispose() {
1088 assert(debugMaybeDispatchDisposed(this));
1089 for (final _HeroFlight flight in _flights.values) {
1090 flight.dispose();
1091 }
1092 }
1093}
1094
1095/// Enables or disables [Hero]es in the widget subtree.
1096///
1097/// {@youtube 560 315 https://www.youtube.com/watch?v=AaIASk2u1C0}
1098///
1099/// When [enabled] is false, all [Hero] widgets in this subtree will not be
1100/// involved in hero animations.
1101///
1102/// When [enabled] is true (the default), [Hero] widgets may be involved in
1103/// hero animations, as usual.
1104class HeroMode extends StatelessWidget {
1105 /// Creates a widget that enables or disables [Hero]es.
1106 const HeroMode({super.key, required this.child, this.enabled = true});
1107
1108 /// The subtree to place inside the [HeroMode].
1109 final Widget child;
1110
1111 /// Whether or not [Hero]es are enabled in this subtree.
1112 ///
1113 /// If this property is false, the [Hero]es in this subtree will not animate
1114 /// on route changes. Otherwise, they will animate as usual.
1115 ///
1116 /// Defaults to true.
1117 final bool enabled;
1118
1119 @override
1120 Widget build(BuildContext context) => child;
1121
1122 @override
1123 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
1124 super.debugFillProperties(properties);
1125 properties.add(
1126 FlagProperty('mode', value: enabled, ifTrue: 'enabled', ifFalse: 'disabled', showName: true),
1127 );
1128 }
1129}
1130

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com