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 | import 'package:flutter/foundation.dart'; |
6 | import 'basic.dart'; |
7 | import 'binding.dart'; |
8 | import 'framework.dart'; |
9 | import 'implicit_animations.dart'; |
10 | import 'media_query.dart'; |
11 | import 'navigator.dart'; |
12 | import 'overlay.dart'; |
13 | import 'pages.dart'; |
14 | import 'routes.dart'; |
15 | import 'ticker_provider.dart' show TickerMode; |
16 | import '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]. |
24 | typedef 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. |
37 | typedef 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). |
46 | typedef HeroFlightShuttleBuilder = Widget Function( |
47 | BuildContext flightContext, |
48 | Animation<double> animation, |
49 | HeroFlightDirection flightDirection, |
50 | BuildContext fromHeroContext, |
51 | BuildContext toHeroContext, |
52 | ); |
53 | |
54 | typedef _OnFlightEnded = void Function(_HeroFlight flight); |
55 | |
56 | /// Direction of the hero's flight based on the navigation operation. |
57 | enum 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) |
170 | class 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. |
345 | class _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 |
429 | class _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. |
511 | class _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]. |
782 | class 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. |
1077 | class 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 | |