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