1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:ui' as ui;
6
7import 'package:flutter/cupertino.dart';
8import 'package:flutter/foundation.dart';
9import 'package:flutter/material.dart';
10import 'package:flutter/rendering.dart';
11import 'package:flutter_test/flutter_test.dart';
12import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
13
14import '../painting/image_test_utils.dart' show TestImageProvider;
15
16Future<ui.Image> createTestImage() {
17 final ui.Paint paint =
18 ui.Paint()
19 ..style = ui.PaintingStyle.stroke
20 ..strokeWidth = 1.0;
21 final ui.PictureRecorder recorder = ui.PictureRecorder();
22 final ui.Canvas pictureCanvas = ui.Canvas(recorder);
23 pictureCanvas.drawCircle(Offset.zero, 20.0, paint);
24 final ui.Picture picture = recorder.endRecording();
25 return picture.toImage(300, 300);
26}
27
28Key firstKey = const Key('first');
29Key secondKey = const Key('second');
30Key thirdKey = const Key('third');
31Key simpleKey = const Key('simple');
32
33Key homeRouteKey = const Key('homeRoute');
34Key routeTwoKey = const Key('routeTwo');
35Key routeThreeKey = const Key('routeThree');
36
37bool transitionFromUserGestures = false;
38
39final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
40 '/':
41 (BuildContext context) => Material(
42 child: ListView(
43 key: homeRouteKey,
44 children: <Widget>[
45 const SizedBox(height: 100.0, width: 100.0),
46 Card(
47 child: Hero(
48 tag: 'a',
49 transitionOnUserGestures: transitionFromUserGestures,
50 child: SizedBox(height: 100.0, width: 100.0, key: firstKey),
51 ),
52 ),
53 const SizedBox(height: 100.0, width: 100.0),
54 TextButton(
55 child: const Text('two'),
56 onPressed: () {
57 Navigator.pushNamed(context, '/two');
58 },
59 ),
60 TextButton(
61 child: const Text('twoInset'),
62 onPressed: () {
63 Navigator.pushNamed(context, '/twoInset');
64 },
65 ),
66 TextButton(
67 child: const Text('simple'),
68 onPressed: () {
69 Navigator.pushNamed(context, '/simple');
70 },
71 ),
72 ],
73 ),
74 ),
75 '/two':
76 (BuildContext context) => Material(
77 child: ListView(
78 key: routeTwoKey,
79 children: <Widget>[
80 TextButton(
81 child: const Text('pop'),
82 onPressed: () {
83 Navigator.pop(context);
84 },
85 ),
86 const SizedBox(height: 150.0, width: 150.0),
87 Card(
88 child: Hero(
89 tag: 'a',
90 transitionOnUserGestures: transitionFromUserGestures,
91 child: SizedBox(height: 150.0, width: 150.0, key: secondKey),
92 ),
93 ),
94 const SizedBox(height: 150.0, width: 150.0),
95 TextButton(
96 child: const Text('three'),
97 onPressed: () {
98 Navigator.push(context, ThreeRoute());
99 },
100 ),
101 ],
102 ),
103 ),
104 // This route is the same as /two except that Hero 'a' is shifted to the right by
105 // 50 pixels. When the hero's in-flight bounds between / and /twoInset are animated
106 // using MaterialRectArcTween (the default) they'll follow a different path
107 // then when the flight starts at /twoInset and returns to /.
108 '/twoInset':
109 (BuildContext context) => Material(
110 child: ListView(
111 key: routeTwoKey,
112 children: <Widget>[
113 TextButton(
114 child: const Text('pop'),
115 onPressed: () {
116 Navigator.pop(context);
117 },
118 ),
119 const SizedBox(height: 150.0, width: 150.0),
120 Card(
121 child: Padding(
122 padding: const EdgeInsets.only(left: 50.0),
123 child: Hero(
124 tag: 'a',
125 transitionOnUserGestures: transitionFromUserGestures,
126 child: SizedBox(height: 150.0, width: 150.0, key: secondKey),
127 ),
128 ),
129 ),
130 const SizedBox(height: 150.0, width: 150.0),
131 TextButton(
132 child: const Text('three'),
133 onPressed: () {
134 Navigator.push(context, ThreeRoute());
135 },
136 ),
137 ],
138 ),
139 ),
140 // This route is the same as /two except that Hero 'a' is shifted to the right by
141 // 50 pixels. When the hero's in-flight bounds between / and /twoInset are animated
142 // using MaterialRectArcTween (the default) they'll follow a different path
143 // then when the flight starts at /twoInset and returns to /.
144 '/simple':
145 (BuildContext context) => CupertinoPageScaffold(
146 child: Center(
147 child: Hero(
148 tag: 'a',
149 transitionOnUserGestures: transitionFromUserGestures,
150 child: SizedBox(height: 150.0, width: 150.0, key: simpleKey),
151 ),
152 ),
153 ),
154};
155
156class ThreeRoute extends MaterialPageRoute<void> {
157 ThreeRoute()
158 : super(
159 builder: (BuildContext context) {
160 return Material(
161 key: routeThreeKey,
162 child: ListView(
163 children: <Widget>[
164 const SizedBox(height: 200.0, width: 200.0),
165 Card(
166 child: Hero(
167 tag: 'a',
168 child: SizedBox(height: 200.0, width: 200.0, key: thirdKey),
169 ),
170 ),
171 const SizedBox(height: 200.0, width: 200.0),
172 ],
173 ),
174 );
175 },
176 );
177}
178
179class MutatingRoute extends MaterialPageRoute<void> {
180 MutatingRoute()
181 : super(
182 builder: (BuildContext context) {
183 return Hero(tag: 'a', key: UniqueKey(), child: const Text('MutatingRoute'));
184 },
185 );
186
187 void markNeedsBuild() {
188 setState(() {
189 // Trigger a rebuild
190 });
191 }
192}
193
194class _SimpleStatefulWidget extends StatefulWidget {
195 const _SimpleStatefulWidget({super.key});
196 @override
197 _SimpleState createState() => _SimpleState();
198}
199
200class _SimpleState extends State<_SimpleStatefulWidget> {
201 int state = 0;
202
203 @override
204 Widget build(BuildContext context) => Text(state.toString());
205}
206
207class MyStatefulWidget extends StatefulWidget {
208 const MyStatefulWidget({super.key, this.value = '123'});
209 final String value;
210 @override
211 MyStatefulWidgetState createState() => MyStatefulWidgetState();
212}
213
214class MyStatefulWidgetState extends State<MyStatefulWidget> {
215 @override
216 Widget build(BuildContext context) => Text(widget.value);
217}
218
219Future<void> main() async {
220 final ui.Image testImage = await createTestImage();
221
222 setUp(() {
223 transitionFromUserGestures = false;
224 });
225
226 testWidgets('Heroes animate', (WidgetTester tester) async {
227 await tester.pumpWidget(MaterialApp(routes: routes));
228
229 // the initial setup.
230
231 expect(find.byKey(firstKey), isOnstage);
232 expect(find.byKey(firstKey), isInCard);
233 expect(find.byKey(secondKey), findsNothing);
234
235 await tester.tap(find.text('two'));
236 await tester.pump(); // begin navigation
237
238 // at this stage, the second route is offstage, so that we can form the
239 // hero party.
240
241 expect(find.byKey(firstKey), isOnstage);
242 expect(find.byKey(firstKey), isInCard);
243 expect(find.byKey(secondKey, skipOffstage: false), isOffstage);
244 expect(find.byKey(secondKey, skipOffstage: false), isInCard);
245
246 await tester.pump();
247
248 // at this stage, the heroes have just gone on their journey, we are
249 // seeing them at t=16ms. The original page no longer contains the hero.
250
251 expect(find.byKey(firstKey), findsNothing);
252
253 expect(find.byKey(secondKey), findsOneWidget);
254 expect(find.byKey(secondKey), isNotInCard);
255 expect(find.byKey(secondKey), isOnstage);
256
257 await tester.pump();
258
259 // t=32ms for the journey. Surely they are still at it.
260
261 expect(find.byKey(firstKey), findsNothing);
262
263 expect(find.byKey(secondKey), findsOneWidget);
264
265 expect(find.byKey(secondKey), findsOneWidget);
266 expect(find.byKey(secondKey), isNotInCard);
267 expect(find.byKey(secondKey), isOnstage);
268
269 await tester.pump(const Duration(seconds: 1));
270
271 // t=1.032s for the journey. The journey has ended (it ends this frame, in
272 // fact). The hero should now be in the new page, onstage. The original
273 // widget will be back as well now (though not visible).
274
275 expect(find.byKey(firstKey), findsNothing);
276 expect(find.byKey(secondKey), isOnstage);
277 expect(find.byKey(secondKey), isInCard);
278
279 await tester.pump();
280
281 // Should not change anything.
282
283 expect(find.byKey(firstKey), findsNothing);
284 expect(find.byKey(secondKey), isOnstage);
285 expect(find.byKey(secondKey), isInCard);
286
287 // Now move on to view 3
288
289 await tester.tap(find.text('three'));
290 await tester.pump(); // begin navigation
291
292 // at this stage, the second route is offstage, so that we can form the
293 // hero party.
294
295 expect(find.byKey(secondKey), isOnstage);
296 expect(find.byKey(secondKey), isInCard);
297 expect(find.byKey(thirdKey, skipOffstage: false), isOffstage);
298 expect(find.byKey(thirdKey, skipOffstage: false), isInCard);
299
300 await tester.pump();
301
302 // at this stage, the heroes have just gone on their journey, we are
303 // seeing them at t=16ms. The original page no longer contains the hero.
304
305 expect(find.byKey(secondKey), findsNothing);
306 expect(find.byKey(thirdKey), isOnstage);
307 expect(find.byKey(thirdKey), isNotInCard);
308
309 await tester.pump();
310
311 // t=32ms for the journey. Surely they are still at it.
312
313 expect(find.byKey(secondKey), findsNothing);
314 expect(find.byKey(thirdKey), isOnstage);
315 expect(find.byKey(thirdKey), isNotInCard);
316
317 await tester.pump(const Duration(seconds: 1));
318
319 // t=1.032s for the journey. The journey has ended (it ends this frame, in
320 // fact). The hero should now be in the new page, onstage.
321
322 expect(find.byKey(secondKey), findsNothing);
323 expect(find.byKey(thirdKey), isOnstage);
324 expect(find.byKey(thirdKey), isInCard);
325
326 await tester.pump();
327
328 // Should not change anything.
329
330 expect(find.byKey(secondKey), findsNothing);
331 expect(find.byKey(thirdKey), isOnstage);
332 expect(find.byKey(thirdKey), isInCard);
333 });
334
335 testWidgets('Heroes still animate after hero controller is swapped.', (
336 WidgetTester tester,
337 ) async {
338 final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
339 final UniqueKey heroKey = UniqueKey();
340 final HeroController controller1 = HeroController();
341 addTearDown(controller1.dispose);
342
343 await tester.pumpWidget(
344 HeroControllerScope(
345 controller: controller1,
346 child: TestDependencies(
347 child: Navigator(
348 key: key,
349 initialRoute: 'navigator1',
350 onGenerateRoute: (RouteSettings s) {
351 return MaterialPageRoute<void>(
352 builder: (BuildContext c) {
353 return Hero(
354 tag: 'hero',
355 child: Container(),
356 flightShuttleBuilder: (
357 BuildContext flightContext,
358 Animation<double> animation,
359 HeroFlightDirection flightDirection,
360 BuildContext fromHeroContext,
361 BuildContext toHeroContext,
362 ) {
363 return Container(key: heroKey);
364 },
365 );
366 },
367 settings: s,
368 );
369 },
370 ),
371 ),
372 ),
373 );
374 key.currentState!.push(
375 MaterialPageRoute<void>(
376 builder: (BuildContext c) {
377 return Hero(
378 tag: 'hero',
379 child: Container(),
380 flightShuttleBuilder: (
381 BuildContext flightContext,
382 Animation<double> animation,
383 HeroFlightDirection flightDirection,
384 BuildContext fromHeroContext,
385 BuildContext toHeroContext,
386 ) {
387 return Container(key: heroKey);
388 },
389 );
390 },
391 ),
392 );
393
394 expect(find.byKey(heroKey), findsNothing);
395 // Begins the navigation
396 await tester.pump();
397 await tester.pump(const Duration(milliseconds: 30));
398 expect(find.byKey(heroKey), isOnstage);
399 final HeroController controller2 = HeroController();
400 addTearDown(controller2.dispose);
401
402 // Pumps a new hero controller.
403 await tester.pumpWidget(
404 HeroControllerScope(
405 controller: controller2,
406 child: TestDependencies(
407 child: Navigator(
408 key: key,
409 initialRoute: 'navigator1',
410 onGenerateRoute: (RouteSettings s) {
411 return MaterialPageRoute<void>(
412 builder: (BuildContext c) {
413 return Hero(
414 tag: 'hero',
415 child: Container(),
416 flightShuttleBuilder: (
417 BuildContext flightContext,
418 Animation<double> animation,
419 HeroFlightDirection flightDirection,
420 BuildContext fromHeroContext,
421 BuildContext toHeroContext,
422 ) {
423 return Container(key: heroKey);
424 },
425 );
426 },
427 settings: s,
428 );
429 },
430 ),
431 ),
432 ),
433 );
434
435 // The original animation still flies.
436 expect(find.byKey(heroKey), isOnstage);
437 // Waits for the animation finishes.
438 await tester.pumpAndSettle();
439 expect(find.byKey(heroKey), findsNothing);
440 });
441
442 testWidgets('Heroes animate should hide original hero', (WidgetTester tester) async {
443 await tester.pumpWidget(MaterialApp(routes: routes));
444 // Checks initial state.
445 expect(find.byKey(firstKey), isOnstage);
446 expect(find.byKey(firstKey), isInCard);
447 expect(find.byKey(secondKey), findsNothing);
448
449 await tester.tap(find.text('two'));
450 await tester.pumpAndSettle(); // Waits for transition finishes.
451
452 expect(find.byKey(firstKey), findsNothing);
453 final Offstage first = tester.widget(
454 find
455 .ancestor(
456 of: find.byKey(firstKey, skipOffstage: false),
457 matching: find.byType(Offstage, skipOffstage: false),
458 )
459 .first,
460 );
461 // Original hero should stay hidden.
462 expect(first.offstage, isTrue);
463 expect(find.byKey(secondKey), isOnstage);
464 expect(find.byKey(secondKey), isInCard);
465 });
466
467 testWidgets('Destination hero is rebuilt midflight', (WidgetTester tester) async {
468 final MutatingRoute route = MutatingRoute();
469
470 await tester.pumpWidget(
471 MaterialApp(
472 home: Material(
473 child: ListView(
474 children: <Widget>[
475 const Hero(tag: 'a', child: Text('foo')),
476 Builder(
477 builder: (BuildContext context) {
478 return TextButton(
479 child: const Text('two'),
480 onPressed: () => Navigator.push(context, route),
481 );
482 },
483 ),
484 ],
485 ),
486 ),
487 ),
488 );
489
490 await tester.tap(find.text('two'));
491 await tester.pump(const Duration(milliseconds: 10));
492
493 route.markNeedsBuild();
494
495 await tester.pump(const Duration(milliseconds: 10));
496 await tester.pump(const Duration(seconds: 1));
497 });
498
499 testWidgets('Heroes animation is fastOutSlowIn', (WidgetTester tester) async {
500 await tester.pumpWidget(MaterialApp(routes: routes));
501 await tester.tap(find.text('two'));
502 await tester.pump(); // begin navigation
503
504 // Expect the height of the secondKey Hero to vary from 100 to 150
505 // over duration and according to curve.
506
507 const Duration duration = Duration(milliseconds: 300);
508 const Curve curve = Curves.fastOutSlowIn;
509 final double initialHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height;
510 final double finalHeight = tester.getSize(find.byKey(secondKey, skipOffstage: false)).height;
511 final double deltaHeight = finalHeight - initialHeight;
512 const double epsilon = 0.001;
513
514 await tester.pump(duration * 0.25);
515 expect(
516 tester.getSize(find.byKey(secondKey)).height,
517 moreOrLessEquals(curve.transform(0.25) * deltaHeight + initialHeight, epsilon: epsilon),
518 );
519
520 await tester.pump(duration * 0.25);
521 expect(
522 tester.getSize(find.byKey(secondKey)).height,
523 moreOrLessEquals(curve.transform(0.50) * deltaHeight + initialHeight, epsilon: epsilon),
524 );
525
526 await tester.pump(duration * 0.25);
527 expect(
528 tester.getSize(find.byKey(secondKey)).height,
529 moreOrLessEquals(curve.transform(0.75) * deltaHeight + initialHeight, epsilon: epsilon),
530 );
531
532 await tester.pump(duration * 0.25);
533 expect(
534 tester.getSize(find.byKey(secondKey)).height,
535 moreOrLessEquals(curve.transform(1.0) * deltaHeight + initialHeight, epsilon: epsilon),
536 );
537 });
538
539 testWidgets('Heroes are not interactive', (WidgetTester tester) async {
540 final List<String> log = <String>[];
541
542 await tester.pumpWidget(
543 MaterialApp(
544 home: Center(
545 child: Hero(
546 tag: 'foo',
547 child: GestureDetector(
548 onTap: () {
549 log.add('foo');
550 },
551 child: const SizedBox(width: 100.0, height: 100.0, child: Text('foo')),
552 ),
553 ),
554 ),
555 routes: <String, WidgetBuilder>{
556 '/next': (BuildContext context) {
557 return Align(
558 alignment: Alignment.topLeft,
559 child: Hero(
560 tag: 'foo',
561 child: GestureDetector(
562 onTap: () {
563 log.add('bar');
564 },
565 child: const SizedBox(width: 100.0, height: 150.0, child: Text('bar')),
566 ),
567 ),
568 );
569 },
570 },
571 ),
572 );
573
574 expect(log, isEmpty);
575 await tester.tap(find.text('foo'));
576 expect(log, equals(<String>['foo']));
577 log.clear();
578
579 final NavigatorState navigator = tester.state(find.byType(Navigator));
580 navigator.pushNamed('/next');
581
582 expect(log, isEmpty);
583 await tester.tap(find.text('foo', skipOffstage: false), warnIfMissed: false);
584 expect(log, isEmpty);
585
586 await tester.pump(const Duration(milliseconds: 10));
587 await tester.tap(find.text('foo', skipOffstage: false), warnIfMissed: false);
588 expect(log, isEmpty);
589 await tester.tap(find.text('bar', skipOffstage: false), warnIfMissed: false);
590 expect(log, isEmpty);
591
592 await tester.pump(const Duration(milliseconds: 10));
593 expect(find.text('foo'), findsNothing);
594 await tester.tap(find.text('bar', skipOffstage: false), warnIfMissed: false);
595 expect(log, isEmpty);
596
597 await tester.pump(const Duration(seconds: 1));
598 expect(find.text('foo'), findsNothing);
599 await tester.tap(find.text('bar'));
600 expect(log, equals(<String>['bar']));
601 });
602
603 testWidgets('Popping on first frame does not cause hero observer to crash', (
604 WidgetTester tester,
605 ) async {
606 await tester.pumpWidget(
607 MaterialApp(
608 onGenerateRoute: (RouteSettings settings) {
609 return MaterialPageRoute<void>(
610 settings: settings,
611 builder: (BuildContext context) => Hero(tag: 'test', child: Container()),
612 );
613 },
614 ),
615 );
616 await tester.pump();
617
618 final Finder heroes = find.byType(Hero);
619 expect(heroes, findsOneWidget);
620
621 Navigator.pushNamed(heroes.evaluate().first, 'test');
622 await tester.pump(); // adds the new page to the tree...
623
624 Navigator.pop(heroes.evaluate().first);
625 await tester.pump(); // ...and removes it straight away (since it's already at 0.0)
626 });
627
628 testWidgets('Overlapping starting and ending a hero transition works ok', (
629 WidgetTester tester,
630 ) async {
631 await tester.pumpWidget(
632 MaterialApp(
633 onGenerateRoute: (RouteSettings settings) {
634 return MaterialPageRoute<void>(
635 settings: settings,
636 builder: (BuildContext context) => Hero(tag: 'test', child: Container()),
637 );
638 },
639 ),
640 );
641 await tester.pump();
642
643 final Finder heroes = find.byType(Hero);
644 expect(heroes, findsOneWidget);
645
646 Navigator.pushNamed(heroes.evaluate().first, 'test');
647 await tester.pump();
648 await tester.pump(const Duration(hours: 1));
649
650 Navigator.pushNamed(heroes.evaluate().first, 'test');
651 await tester.pump();
652 await tester.pump(const Duration(hours: 1));
653
654 Navigator.pop(heroes.evaluate().first);
655 await tester.pump();
656 Navigator.pop(heroes.evaluate().first);
657 await tester.pump(
658 const Duration(hours: 1),
659 ); // so the first transition is finished, but the second hasn't started
660 await tester.pump();
661 });
662
663 testWidgets('One route, two heroes, same tag, throws', (WidgetTester tester) async {
664 await tester.pumpWidget(
665 MaterialApp(
666 home: Material(
667 child: ListView(
668 children: <Widget>[
669 const Hero(tag: 'a', child: Text('a')),
670 const Hero(tag: 'a', child: Text('a too')),
671 Builder(
672 builder: (BuildContext context) {
673 return TextButton(
674 child: const Text('push'),
675 onPressed: () {
676 Navigator.push(
677 context,
678 PageRouteBuilder<void>(
679 pageBuilder: (
680 BuildContext context,
681 Animation<double> _,
682 Animation<double> _,
683 ) {
684 return const Text('fail');
685 },
686 ),
687 );
688 },
689 );
690 },
691 ),
692 ],
693 ),
694 ),
695 ),
696 );
697
698 await tester.tap(find.text('push'));
699 await tester.pump();
700 final dynamic exception = tester.takeException();
701 expect(exception, isFlutterError);
702 final FlutterError error = exception as FlutterError;
703 expect(error.diagnostics.length, 3);
704 final DiagnosticsNode last = error.diagnostics.last;
705 expect(last, isA<DiagnosticsProperty<StatefulElement>>());
706 expect(
707 last.toStringDeep(),
708 equalsIgnoringHashCodes('# Here is the subtree for one of the offending heroes: Hero\n'),
709 );
710 expect(last.style, DiagnosticsTreeStyle.dense);
711 expect(
712 error.toStringDeep(),
713 equalsIgnoringHashCodes(
714 'FlutterError\n'
715 ' There are multiple heroes that share the same tag within a\n'
716 ' subtree.\n'
717 ' Within each subtree for which heroes are to be animated (i.e. a\n'
718 ' PageRoute subtree), each Hero must have a unique non-null tag.\n'
719 ' In this case, multiple heroes had the following tag: a\n'
720 ' ├# Here is the subtree for one of the offending heroes: Hero\n',
721 ),
722 );
723 });
724
725 testWidgets('Hero push transition interrupted by a pop', (WidgetTester tester) async {
726 await tester.pumpWidget(MaterialApp(routes: routes));
727
728 // Initially the firstKey Card on the '/' route is visible
729 expect(find.byKey(firstKey), isOnstage);
730 expect(find.byKey(firstKey), isInCard);
731 expect(find.byKey(secondKey), findsNothing);
732
733 // Pushes MaterialPageRoute '/two'.
734 await tester.tap(find.text('two'));
735
736 // Start the flight of Hero 'a' from route '/' to route '/two'. Route '/two'
737 // is now offstage.
738 await tester.pump();
739
740 final double initialHeight = tester.getSize(find.byKey(firstKey)).height;
741 final double finalHeight = tester.getSize(find.byKey(secondKey, skipOffstage: false)).height;
742 expect(finalHeight, greaterThan(initialHeight)); // simplify the checks below
743
744 // Build the first hero animation frame in the navigator's overlay.
745 await tester.pump();
746
747 // At this point the hero widgets have been replaced by placeholders
748 // and the destination hero has been moved to the overlay.
749 expect(
750 find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)),
751 findsNothing,
752 );
753 expect(
754 find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)),
755 findsNothing,
756 );
757 expect(find.byKey(firstKey), findsNothing);
758 expect(find.byKey(secondKey), isOnstage);
759
760 // The duration of a MaterialPageRoute's transition is 300ms.
761 // At 150ms Hero 'a' is mid-flight.
762 await tester.pump(const Duration(milliseconds: 150));
763 final double height150ms = tester.getSize(find.byKey(secondKey)).height;
764 expect(height150ms, greaterThan(initialHeight));
765 expect(height150ms, lessThan(finalHeight));
766
767 // Pop route '/two' before the push transition to '/two' has finished.
768 await tester.tap(find.text('pop'));
769
770 // Restart the flight of Hero 'a'. Now it's flying from route '/two' to
771 // route '/'.
772 await tester.pump();
773
774 // After flying in the opposite direction for 50ms Hero 'a' will
775 // be smaller than it was, but bigger than its initial size.
776 await tester.pump(const Duration(milliseconds: 50));
777 final double height100ms = tester.getSize(find.byKey(secondKey)).height;
778 expect(height100ms, lessThan(height150ms));
779 expect(finalHeight, greaterThan(height100ms));
780
781 // Hero a's return flight at 149ms. The outgoing (push) flight took
782 // 150ms so we should be just about back to where Hero 'a' started.
783 const double epsilon = 0.001;
784 await tester.pump(const Duration(milliseconds: 99));
785 moreOrLessEquals(
786 tester.getSize(find.byKey(secondKey)).height - initialHeight,
787 epsilon: epsilon,
788 );
789
790 // The flight is finished. We're back to where we started.
791 await tester.pump(const Duration(milliseconds: 300));
792 expect(find.byKey(firstKey), isOnstage);
793 expect(find.byKey(firstKey), isInCard);
794 expect(find.byKey(secondKey), findsNothing);
795 });
796
797 testWidgets(
798 'Hero pop transition interrupted by a push',
799 (WidgetTester tester) async {
800 await tester.pumpWidget(
801 MaterialApp(
802 routes: routes,
803 theme: ThemeData(
804 pageTransitionsTheme: const PageTransitionsTheme(
805 builders: <TargetPlatform, PageTransitionsBuilder>{
806 TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
807 },
808 ),
809 ),
810 ),
811 );
812
813 // Pushes MaterialPageRoute '/two'.
814 await tester.tap(find.text('two'));
815 await tester.pump();
816 await tester.pump(const Duration(seconds: 1));
817
818 // Now the secondKey Card on the '/2' route is visible
819 expect(find.byKey(secondKey), isOnstage);
820 expect(find.byKey(secondKey), isInCard);
821 expect(find.byKey(firstKey), findsNothing);
822
823 // Pop MaterialPageRoute '/two'.
824 await tester.tap(find.text('pop'));
825
826 // Start the flight of Hero 'a' from route '/two' to route '/'. Route '/two'
827 // is now offstage.
828 await tester.pump();
829
830 final double initialHeight = tester.getSize(find.byKey(secondKey)).height;
831 final double finalHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height;
832 expect(finalHeight, lessThan(initialHeight)); // simplify the checks below
833
834 // Build the first hero animation frame in the navigator's overlay.
835 await tester.pump();
836
837 // At this point the hero widgets have been replaced by placeholders
838 // and the destination hero has been moved to the overlay.
839 expect(
840 find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)),
841 findsNothing,
842 );
843 expect(
844 find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)),
845 findsNothing,
846 );
847 expect(find.byKey(firstKey), isOnstage);
848 expect(find.byKey(secondKey), findsNothing);
849
850 // The duration of a MaterialPageRoute's transition is 300ms.
851 // At 150ms Hero 'a' is mid-flight.
852 await tester.pump(const Duration(milliseconds: 150));
853 final double height150ms = tester.getSize(find.byKey(firstKey)).height;
854 expect(height150ms, lessThan(initialHeight));
855 expect(height150ms, greaterThan(finalHeight));
856
857 // Push route '/two' before the pop transition from '/two' has finished.
858 await tester.tap(find.text('two'));
859
860 // Restart the flight of Hero 'a'. Now it's flying from route '/' to
861 // route '/two'.
862 await tester.pump();
863
864 // After flying in the opposite direction for 50ms Hero 'a' will
865 // be smaller than it was, but bigger than its initial size.
866 await tester.pump(const Duration(milliseconds: 50));
867 final double height200ms = tester.getSize(find.byKey(firstKey)).height;
868 expect(height200ms, greaterThan(height150ms));
869 expect(finalHeight, lessThan(height200ms));
870
871 // Hero a's return flight at 149ms. The outgoing (push) flight took
872 // 150ms so we should be just about back to where Hero 'a' started.
873 const double epsilon = 0.001;
874 await tester.pump(const Duration(milliseconds: 99));
875 moreOrLessEquals(
876 tester.getSize(find.byKey(firstKey)).height - initialHeight,
877 epsilon: epsilon,
878 );
879
880 // The flight is finished. We're back to where we started.
881 await tester.pump(const Duration(milliseconds: 300));
882 expect(find.byKey(secondKey), isOnstage);
883 expect(find.byKey(secondKey), isInCard);
884 expect(find.byKey(firstKey), findsNothing);
885 },
886 variant: const TargetPlatformVariant(<TargetPlatform>{
887 TargetPlatform.android,
888 TargetPlatform.linux,
889 }),
890 );
891
892 testWidgets('Destination hero disappears mid-flight', (WidgetTester tester) async {
893 const Key homeHeroKey = Key('home hero');
894 const Key routeHeroKey = Key('route hero');
895 bool routeIncludesHero = true;
896 late StateSetter heroCardSetState;
897
898 // Show a 200x200 Hero tagged 'H', with key routeHeroKey
899 final MaterialPageRoute<void> route = MaterialPageRoute<void>(
900 builder: (BuildContext context) {
901 return Material(
902 child: ListView(
903 children: <Widget>[
904 StatefulBuilder(
905 builder: (BuildContext context, StateSetter setState) {
906 heroCardSetState = setState;
907 return Card(
908 child:
909 routeIncludesHero
910 ? const Hero(
911 tag: 'H',
912 child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0),
913 )
914 : const SizedBox(height: 200.0, width: 200.0),
915 );
916 },
917 ),
918 TextButton(
919 child: const Text('POP'),
920 onPressed: () {
921 Navigator.pop(context);
922 },
923 ),
924 ],
925 ),
926 );
927 },
928 );
929
930 // Show a 100x100 Hero tagged 'H' with key homeHeroKey
931 await tester.pumpWidget(
932 MaterialApp(
933 home: Scaffold(
934 body: Builder(
935 builder: (BuildContext context) {
936 // Navigator.push() needs context
937 return ListView(
938 children: <Widget>[
939 const Card(
940 child: Hero(
941 tag: 'H',
942 child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0),
943 ),
944 ),
945 TextButton(
946 child: const Text('PUSH'),
947 onPressed: () {
948 Navigator.push(context, route);
949 },
950 ),
951 ],
952 );
953 },
954 ),
955 ),
956 ),
957 );
958
959 // Pushes route
960 await tester.tap(find.text('PUSH'));
961 await tester.pump();
962 await tester.pump();
963 final double initialHeight = tester.getSize(find.byKey(routeHeroKey)).height;
964
965 await tester.pump(const Duration(milliseconds: 10));
966 double midflightHeight = tester.getSize(find.byKey(routeHeroKey)).height;
967 expect(midflightHeight, greaterThan(initialHeight));
968 expect(midflightHeight, lessThan(200.0));
969
970 await tester.pump(const Duration(milliseconds: 300));
971 await tester.pump();
972 double finalHeight = tester.getSize(find.byKey(routeHeroKey)).height;
973 expect(finalHeight, 200.0);
974
975 // Complete the flight
976 await tester.pump(const Duration(milliseconds: 100));
977
978 // Rebuild route with its Hero
979
980 heroCardSetState(() {
981 routeIncludesHero = true;
982 });
983 await tester.pump();
984
985 // Pops route
986 await tester.tap(find.text('POP'));
987 await tester.pump();
988 await tester.pump();
989
990 await tester.pump(const Duration(milliseconds: 10));
991 midflightHeight = tester.getSize(find.byKey(homeHeroKey)).height;
992 expect(midflightHeight, lessThan(finalHeight));
993 expect(midflightHeight, greaterThan(100.0));
994
995 // Remove the destination hero midflight
996 heroCardSetState(() {
997 routeIncludesHero = false;
998 });
999 await tester.pump();
1000
1001 await tester.pump(const Duration(milliseconds: 300));
1002 finalHeight = tester.getSize(find.byKey(homeHeroKey)).height;
1003 expect(finalHeight, 100.0);
1004 });
1005
1006 testWidgets('Destination hero scrolls mid-flight', (WidgetTester tester) async {
1007 const Key homeHeroKey = Key('home hero');
1008 const Key routeHeroKey = Key('route hero');
1009 const Key routeContainerKey = Key('route hero container');
1010
1011 // Show a 200x200 Hero tagged 'H', with key routeHeroKey
1012 final MaterialPageRoute<void> route = MaterialPageRoute<void>(
1013 builder: (BuildContext context) {
1014 return Material(
1015 child: ListView(
1016 children: <Widget>[
1017 const SizedBox(height: 100.0),
1018 // This container will appear at Y=100
1019 Container(
1020 key: routeContainerKey,
1021 child: const Hero(
1022 tag: 'H',
1023 child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0),
1024 ),
1025 ),
1026 TextButton(
1027 child: const Text('POP'),
1028 onPressed: () {
1029 Navigator.pop(context);
1030 },
1031 ),
1032 const SizedBox(height: 600.0),
1033 ],
1034 ),
1035 );
1036 },
1037 );
1038
1039 // Show a 100x100 Hero tagged 'H' with key homeHeroKey
1040 await tester.pumpWidget(
1041 MaterialApp(
1042 theme: ThemeData(
1043 pageTransitionsTheme: const PageTransitionsTheme(
1044 builders: <TargetPlatform, PageTransitionsBuilder>{
1045 TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
1046 },
1047 ),
1048 ),
1049 home: Scaffold(
1050 body: Builder(
1051 builder: (BuildContext context) {
1052 // Navigator.push() needs context
1053 return ListView(
1054 children: <Widget>[
1055 const SizedBox(height: 200.0),
1056 // This container will appear at Y=200
1057 const Hero(
1058 tag: 'H',
1059 child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0),
1060 ),
1061 TextButton(
1062 child: const Text('PUSH'),
1063 onPressed: () {
1064 Navigator.push(context, route);
1065 },
1066 ),
1067 const SizedBox(height: 600.0),
1068 ],
1069 );
1070 },
1071 ),
1072 ),
1073 ),
1074 );
1075
1076 // Pushes route
1077 await tester.tap(find.text('PUSH'));
1078 await tester.pump();
1079 await tester.pump();
1080
1081 final double initialY = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1082 expect(initialY, 200.0);
1083
1084 await tester.pump(const Duration(milliseconds: 100));
1085 final double yAt100ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1086 expect(yAt100ms, lessThan(200.0));
1087 expect(yAt100ms, greaterThan(100.0));
1088
1089 // Scroll the target upwards by 25 pixels. The Hero flight's Y coordinate
1090 // will be redirected from 100 to 75.
1091 await tester.drag(
1092 find.byKey(routeContainerKey),
1093 const Offset(0.0, -25.0),
1094 warnIfMissed: false,
1095 ); // the container itself wouldn't be hit
1096 await tester.pump();
1097 await tester.pump(const Duration(milliseconds: 10));
1098 final double yAt110ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1099 expect(yAt110ms, lessThan(yAt100ms));
1100 expect(yAt110ms, greaterThan(75.0));
1101
1102 await tester.pump(const Duration(milliseconds: 300));
1103 await tester.pump();
1104 final double finalHeroY = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1105 expect(finalHeroY, 75.0); // 100 less 25 for the scroll
1106 });
1107
1108 testWidgets('Destination hero scrolls out of view mid-flight', (WidgetTester tester) async {
1109 const Key homeHeroKey = Key('home hero');
1110 const Key routeHeroKey = Key('route hero');
1111 const Key routeContainerKey = Key('route hero container');
1112
1113 // Show a 200x200 Hero tagged 'H', with key routeHeroKey
1114 final MaterialPageRoute<void> route = MaterialPageRoute<void>(
1115 builder: (BuildContext context) {
1116 return Material(
1117 child: ListView(
1118 cacheExtent: 0.0,
1119 children: <Widget>[
1120 const SizedBox(height: 100.0),
1121 // This container will appear at Y=100
1122 Container(
1123 key: routeContainerKey,
1124 child: const Hero(
1125 tag: 'H',
1126 child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0),
1127 ),
1128 ),
1129 const SizedBox(height: 800.0),
1130 ],
1131 ),
1132 );
1133 },
1134 );
1135
1136 // Show a 100x100 Hero tagged 'H' with key homeHeroKey
1137 await tester.pumpWidget(
1138 MaterialApp(
1139 home: Scaffold(
1140 body: Builder(
1141 builder: (BuildContext context) {
1142 // Navigator.push() needs context
1143 return ListView(
1144 children: <Widget>[
1145 const SizedBox(height: 200.0),
1146 // This container will appear at Y=200
1147 const Hero(
1148 tag: 'H',
1149 child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0),
1150 ),
1151 TextButton(
1152 child: const Text('PUSH'),
1153 onPressed: () {
1154 Navigator.push(context, route);
1155 },
1156 ),
1157 ],
1158 );
1159 },
1160 ),
1161 ),
1162 ),
1163 );
1164
1165 // Pushes route
1166 await tester.tap(find.text('PUSH'));
1167 await tester.pump();
1168 await tester.pump();
1169
1170 final double initialY = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1171 expect(initialY, 200.0);
1172
1173 await tester.pump(const Duration(milliseconds: 100));
1174 final double yAt100ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1175 expect(yAt100ms, lessThan(200.0));
1176 expect(yAt100ms, greaterThan(100.0));
1177
1178 await tester.drag(
1179 find.byKey(routeContainerKey),
1180 const Offset(0.0, -400.0),
1181 warnIfMissed: false,
1182 ); // the container itself wouldn't be hit
1183 await tester.pump();
1184 await tester.pump(const Duration(milliseconds: 10));
1185 expect(find.byKey(routeContainerKey), findsNothing); // Scrolled off the top
1186
1187 // Flight continues (the hero will fade out) even though the destination
1188 // no longer exists.
1189 final double yAt110ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy;
1190 expect(yAt110ms, lessThan(yAt100ms));
1191 expect(yAt110ms, greaterThan(100.0));
1192
1193 await tester.pump(const Duration(milliseconds: 300));
1194 await tester.pump();
1195 expect(find.byKey(routeHeroKey), findsNothing);
1196 });
1197
1198 testWidgets('Aborted flight', (WidgetTester tester) async {
1199 // See https://github.com/flutter/flutter/issues/5798
1200 const Key heroABKey = Key('AB hero');
1201 const Key heroBCKey = Key('BC hero');
1202
1203 // Show a 150x150 Hero tagged 'BC'
1204 final MaterialPageRoute<void> routeC = MaterialPageRoute<void>(
1205 builder: (BuildContext context) {
1206 return Material(
1207 child: ListView(
1208 children: const <Widget>[
1209 // This container will appear at Y=0
1210 Hero(tag: 'BC', child: SizedBox(key: heroBCKey, height: 150.0, child: Text('Hero'))),
1211 SizedBox(height: 800.0),
1212 ],
1213 ),
1214 );
1215 },
1216 );
1217
1218 // Show a height=200 Hero tagged 'AB' and a height=50 Hero tagged 'BC'
1219 final MaterialPageRoute<void> routeB = MaterialPageRoute<void>(
1220 builder: (BuildContext context) {
1221 return Material(
1222 child: ListView(
1223 children: <Widget>[
1224 const SizedBox(height: 100.0),
1225 // This container will appear at Y=100
1226 const Hero(
1227 tag: 'AB',
1228 child: SizedBox(key: heroABKey, height: 200.0, child: Text('Hero')),
1229 ),
1230 TextButton(
1231 child: const Text('PUSH C'),
1232 onPressed: () {
1233 Navigator.push(context, routeC);
1234 },
1235 ),
1236 const Hero(tag: 'BC', child: SizedBox(height: 150.0, child: Text('Hero'))),
1237 const SizedBox(height: 800.0),
1238 ],
1239 ),
1240 );
1241 },
1242 );
1243
1244 // Show a 100x100 Hero tagged 'AB' with key heroABKey
1245 await tester.pumpWidget(
1246 MaterialApp(
1247 home: Scaffold(
1248 body: Builder(
1249 builder: (BuildContext context) {
1250 // Navigator.push() needs context
1251 return ListView(
1252 children: <Widget>[
1253 const SizedBox(height: 200.0),
1254 // This container will appear at Y=200
1255 const Hero(
1256 tag: 'AB',
1257 child: SizedBox(height: 100.0, width: 100.0, child: Text('Hero')),
1258 ),
1259 TextButton(
1260 child: const Text('PUSH B'),
1261 onPressed: () {
1262 Navigator.push(context, routeB);
1263 },
1264 ),
1265 ],
1266 );
1267 },
1268 ),
1269 ),
1270 ),
1271 );
1272
1273 // Pushes routeB
1274 await tester.tap(find.text('PUSH B'));
1275 await tester.pump();
1276 await tester.pump();
1277
1278 final double initialY = tester.getTopLeft(find.byKey(heroABKey)).dy;
1279 expect(initialY, 200.0);
1280
1281 await tester.pump(const Duration(milliseconds: 200));
1282 final double yAt200ms = tester.getTopLeft(find.byKey(heroABKey)).dy;
1283 // Hero AB is mid flight.
1284 expect(yAt200ms, lessThan(200.0));
1285 expect(yAt200ms, greaterThan(100.0));
1286
1287 // Pushes route C, causes hero AB's flight to abort, hero BC's flight to start
1288 await tester.tap(find.text('PUSH C'));
1289 await tester.pump();
1290 await tester.pump();
1291
1292 // Hero AB's aborted flight finishes where it was expected although
1293 // it's been faded out.
1294 await tester.pump(const Duration(milliseconds: 100));
1295 expect(tester.getTopLeft(find.byKey(heroABKey)).dy, 100.0);
1296
1297 bool isVisible(RenderObject node) {
1298 RenderObject? currentNode = node;
1299 while (currentNode != null) {
1300 if (currentNode is RenderAnimatedOpacity && currentNode.opacity.value == 0) {
1301 return false;
1302 }
1303 currentNode = currentNode.parent;
1304 }
1305 return true;
1306 }
1307
1308 // Of all heroes only one should be visible now.
1309 final Iterable<RenderObject> renderObjects = find
1310 .text('Hero')
1311 .evaluate()
1312 .map((Element e) => e.renderObject!);
1313 expect(renderObjects.where(isVisible).length, 1);
1314
1315 // Hero BC's flight finishes normally.
1316 await tester.pump(const Duration(milliseconds: 300));
1317 expect(tester.getTopLeft(find.byKey(heroBCKey)).dy, 0.0);
1318 });
1319
1320 testWidgets('Stateful hero child state survives flight', (WidgetTester tester) async {
1321 final MaterialPageRoute<void> route = MaterialPageRoute<void>(
1322 builder: (BuildContext context) {
1323 return Material(
1324 child: ListView(
1325 children: <Widget>[
1326 const Card(
1327 child: Hero(
1328 tag: 'H',
1329 child: SizedBox(height: 200.0, child: MyStatefulWidget(value: '456')),
1330 ),
1331 ),
1332 TextButton(
1333 child: const Text('POP'),
1334 onPressed: () {
1335 Navigator.pop(context);
1336 },
1337 ),
1338 ],
1339 ),
1340 );
1341 },
1342 );
1343
1344 await tester.pumpWidget(
1345 MaterialApp(
1346 home: Scaffold(
1347 body: Builder(
1348 builder: (BuildContext context) {
1349 // Navigator.push() needs context
1350 return ListView(
1351 children: <Widget>[
1352 const Card(
1353 child: Hero(
1354 tag: 'H',
1355 child: SizedBox(height: 100.0, child: MyStatefulWidget(value: '456')),
1356 ),
1357 ),
1358 TextButton(
1359 child: const Text('PUSH'),
1360 onPressed: () {
1361 Navigator.push(context, route);
1362 },
1363 ),
1364 ],
1365 );
1366 },
1367 ),
1368 ),
1369 ),
1370 );
1371
1372 expect(find.text('456'), findsOneWidget);
1373
1374 // Push route.
1375 await tester.tap(find.text('PUSH'));
1376 await tester.pump();
1377 await tester.pump();
1378
1379 // Push flight underway.
1380 await tester.pump(const Duration(milliseconds: 100));
1381 // Visible in the hero animation.
1382 expect(find.text('456'), findsOneWidget);
1383
1384 // Push flight finished.
1385 await tester.pump(const Duration(milliseconds: 300));
1386 expect(find.text('456'), findsOneWidget);
1387
1388 // Pop route.
1389 await tester.tap(find.text('POP'));
1390 await tester.pump();
1391 await tester.pump();
1392
1393 // Pop flight underway.
1394 await tester.pump(const Duration(milliseconds: 100));
1395 expect(find.text('456'), findsOneWidget);
1396
1397 // Pop flight finished
1398 await tester.pump(const Duration(milliseconds: 300));
1399 expect(find.text('456'), findsOneWidget);
1400 });
1401
1402 testWidgets('Hero createRectTween', (WidgetTester tester) async {
1403 RectTween createRectTween(Rect? begin, Rect? end) {
1404 return MaterialRectCenterArcTween(begin: begin, end: end);
1405 }
1406
1407 final Map<String, WidgetBuilder> createRectTweenHeroRoutes = <String, WidgetBuilder>{
1408 '/':
1409 (BuildContext context) => Material(
1410 child: Column(
1411 crossAxisAlignment: CrossAxisAlignment.start,
1412 children: <Widget>[
1413 Hero(
1414 tag: 'a',
1415 createRectTween: createRectTween,
1416 child: SizedBox(height: 100.0, width: 100.0, key: firstKey),
1417 ),
1418 TextButton(
1419 child: const Text('two'),
1420 onPressed: () {
1421 Navigator.pushNamed(context, '/two');
1422 },
1423 ),
1424 ],
1425 ),
1426 ),
1427 '/two':
1428 (BuildContext context) => Material(
1429 child: Column(
1430 children: <Widget>[
1431 SizedBox(
1432 height: 200.0,
1433 child: TextButton(
1434 child: const Text('pop'),
1435 onPressed: () {
1436 Navigator.pop(context);
1437 },
1438 ),
1439 ),
1440 Hero(
1441 tag: 'a',
1442 createRectTween: createRectTween,
1443 child: SizedBox(height: 200.0, width: 100.0, key: secondKey),
1444 ),
1445 ],
1446 ),
1447 ),
1448 };
1449
1450 await tester.pumpWidget(MaterialApp(routes: createRectTweenHeroRoutes));
1451 expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0));
1452
1453 const double epsilon = 0.001;
1454 const Duration duration = Duration(milliseconds: 300);
1455 const Curve curve = Curves.fastOutSlowIn;
1456 final MaterialPointArcTween pushCenterTween = MaterialPointArcTween(
1457 begin: const Offset(50.0, 50.0),
1458 end: const Offset(400.0, 300.0),
1459 );
1460
1461 await tester.tap(find.text('two'));
1462 await tester.pump(); // begin navigation
1463
1464 // Verify that the center of the secondKey Hero flies along the
1465 // pushCenterTween arc for the push /two flight.
1466
1467 await tester.pump();
1468 expect(tester.getCenter(find.byKey(secondKey)), const Offset(50.0, 50.0));
1469
1470 await tester.pump(duration * 0.25);
1471 Offset actualHeroCenter = tester.getCenter(find.byKey(secondKey));
1472 Offset predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.25));
1473 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1474
1475 await tester.pump(duration * 0.25);
1476 actualHeroCenter = tester.getCenter(find.byKey(secondKey));
1477 predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.5));
1478 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1479
1480 await tester.pump(duration * 0.25);
1481 actualHeroCenter = tester.getCenter(find.byKey(secondKey));
1482 predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.75));
1483 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1484
1485 await tester.pumpAndSettle();
1486 expect(tester.getCenter(find.byKey(secondKey)), const Offset(400.0, 300.0));
1487
1488 // Verify that the center of the firstKey Hero flies along the
1489 // pushCenterTween arc for the pop /two flight.
1490
1491 await tester.tap(find.text('pop'));
1492 await tester.pump(); // begin navigation
1493
1494 final MaterialPointArcTween popCenterTween = MaterialPointArcTween(
1495 begin: const Offset(400.0, 300.0),
1496 end: const Offset(50.0, 50.0),
1497 );
1498 await tester.pump();
1499 expect(tester.getCenter(find.byKey(firstKey)), const Offset(400.0, 300.0));
1500
1501 await tester.pump(duration * 0.25);
1502 actualHeroCenter = tester.getCenter(find.byKey(firstKey));
1503 predictedHeroCenter = popCenterTween.lerp(curve.transform(0.25));
1504 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1505
1506 await tester.pump(duration * 0.25);
1507 actualHeroCenter = tester.getCenter(find.byKey(firstKey));
1508 predictedHeroCenter = popCenterTween.lerp(curve.transform(0.5));
1509 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1510
1511 await tester.pump(duration * 0.25);
1512 actualHeroCenter = tester.getCenter(find.byKey(firstKey));
1513 predictedHeroCenter = popCenterTween.lerp(curve.transform(0.75));
1514 expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter));
1515
1516 await tester.pumpAndSettle();
1517 expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0));
1518 });
1519
1520 testWidgets('Hero createRectTween for Navigator that is not full screen', (
1521 WidgetTester tester,
1522 ) async {
1523 // Regression test for https://github.com/flutter/flutter/issues/25272
1524
1525 RectTween createRectTween(Rect? begin, Rect? end) {
1526 return RectTween(begin: begin, end: end);
1527 }
1528
1529 final Map<String, WidgetBuilder> createRectTweenHeroRoutes = <String, WidgetBuilder>{
1530 '/':
1531 (BuildContext context) => Material(
1532 child: Column(
1533 crossAxisAlignment: CrossAxisAlignment.start,
1534 children: <Widget>[
1535 Hero(
1536 tag: 'a',
1537 createRectTween: createRectTween,
1538 child: SizedBox(height: 100.0, width: 100.0, key: firstKey),
1539 ),
1540 TextButton(
1541 child: const Text('two'),
1542 onPressed: () {
1543 Navigator.pushNamed(context, '/two');
1544 },
1545 ),
1546 ],
1547 ),
1548 ),
1549 '/two':
1550 (BuildContext context) => Material(
1551 child: Column(
1552 children: <Widget>[
1553 SizedBox(
1554 height: 200.0,
1555 child: TextButton(
1556 child: const Text('pop'),
1557 onPressed: () {
1558 Navigator.pop(context);
1559 },
1560 ),
1561 ),
1562 Hero(
1563 tag: 'a',
1564 createRectTween: createRectTween,
1565 child: SizedBox(height: 200.0, width: 100.0, key: secondKey),
1566 ),
1567 ],
1568 ),
1569 ),
1570 };
1571
1572 const double leftPadding = 10.0;
1573
1574 // MaterialApp and its Navigator are offset from the left
1575 await tester.pumpWidget(
1576 Padding(
1577 padding: const EdgeInsets.only(left: leftPadding),
1578 child: MaterialApp(routes: createRectTweenHeroRoutes),
1579 ),
1580 );
1581 expect(tester.getCenter(find.byKey(firstKey)), const Offset(leftPadding + 50.0, 50.0));
1582
1583 const double epsilon = 0.001;
1584 const Duration duration = Duration(milliseconds: 300);
1585 const Curve curve = Curves.fastOutSlowIn;
1586 final RectTween pushRectTween = RectTween(
1587 begin: const Rect.fromLTWH(leftPadding, 0.0, 100.0, 100.0),
1588 end: const Rect.fromLTWH(350.0 + leftPadding / 2, 200.0, 100.0, 200.0),
1589 );
1590
1591 await tester.tap(find.text('two'));
1592 await tester.pump(); // begin navigation
1593
1594 // Verify that the rect of the secondKey Hero transforms as the
1595 // pushRectTween rect for the push /two flight.
1596
1597 await tester.pump();
1598 expect(tester.getCenter(find.byKey(secondKey)), const Offset(50.0 + leftPadding, 50.0));
1599
1600 await tester.pump(duration * 0.25);
1601 Rect actualHeroRect = tester.getRect(find.byKey(secondKey));
1602 Rect predictedHeroRect = pushRectTween.lerp(curve.transform(0.25))!;
1603 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1604
1605 await tester.pump(duration * 0.25);
1606 actualHeroRect = tester.getRect(find.byKey(secondKey));
1607 predictedHeroRect = pushRectTween.lerp(curve.transform(0.5))!;
1608 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1609
1610 await tester.pump(duration * 0.25);
1611 actualHeroRect = tester.getRect(find.byKey(secondKey));
1612 predictedHeroRect = pushRectTween.lerp(curve.transform(0.75))!;
1613 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1614
1615 await tester.pumpAndSettle();
1616 expect(tester.getCenter(find.byKey(secondKey)), const Offset(400.0 + leftPadding / 2, 300.0));
1617
1618 // Verify that the rect of the firstKey Hero transforms as the
1619 // pushRectTween rect for the pop /two flight.
1620
1621 await tester.tap(find.text('pop'));
1622 await tester.pump(); // begin navigation
1623
1624 final RectTween popRectTween = RectTween(
1625 begin: const Rect.fromLTWH(350.0 + leftPadding / 2, 200.0, 100.0, 200.0),
1626 end: const Rect.fromLTWH(leftPadding, 0.0, 100.0, 100.0),
1627 );
1628 await tester.pump();
1629 expect(tester.getCenter(find.byKey(firstKey)), const Offset(400.0 + leftPadding / 2, 300.0));
1630
1631 await tester.pump(duration * 0.25);
1632 actualHeroRect = tester.getRect(find.byKey(firstKey));
1633 predictedHeroRect = popRectTween.lerp(curve.transform(0.25))!;
1634 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1635
1636 await tester.pump(duration * 0.25);
1637 actualHeroRect = tester.getRect(find.byKey(firstKey));
1638 predictedHeroRect = popRectTween.lerp(curve.transform(0.5))!;
1639 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1640
1641 await tester.pump(duration * 0.25);
1642 actualHeroRect = tester.getRect(find.byKey(firstKey));
1643 predictedHeroRect = popRectTween.lerp(curve.transform(0.75))!;
1644 expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect));
1645
1646 await tester.pumpAndSettle();
1647 expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0 + leftPadding, 50.0));
1648 });
1649
1650 testWidgets('Pop interrupts push, reverses flight', (WidgetTester tester) async {
1651 await tester.pumpWidget(MaterialApp(routes: routes));
1652 await tester.tap(find.text('twoInset'));
1653 await tester.pump(); // begin navigation from / to /twoInset.
1654
1655 const double epsilon = 0.001;
1656 const Duration duration = Duration(milliseconds: 300);
1657
1658 await tester.pump();
1659 final double x0 = tester.getTopLeft(find.byKey(secondKey)).dx;
1660
1661 // Flight begins with the secondKey Hero widget lined up with the firstKey widget.
1662 expect(x0, 4.0);
1663
1664 await tester.pump(duration * 0.1);
1665 final double x1 = tester.getTopLeft(find.byKey(secondKey)).dx;
1666
1667 await tester.pump(duration * 0.1);
1668 final double x2 = tester.getTopLeft(find.byKey(secondKey)).dx;
1669
1670 await tester.pump(duration * 0.1);
1671 final double x3 = tester.getTopLeft(find.byKey(secondKey)).dx;
1672
1673 await tester.pump(duration * 0.1);
1674 final double x4 = tester.getTopLeft(find.byKey(secondKey)).dx;
1675
1676 // Pop route /twoInset before the push transition from / to /twoInset has finished.
1677 await tester.tap(find.text('pop'));
1678
1679 // We expect the hero to take the same path as it did flying from /
1680 // to /twoInset as it does now, flying from '/twoInset' back to /. The most
1681 // important checks below are the first (x4) and last (x0): the hero should
1682 // not jump from where it was when the push transition was interrupted by a
1683 // pop, and it should end up where the push started.
1684
1685 await tester.pump();
1686 expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x4, epsilon: epsilon));
1687
1688 await tester.pump(duration * 0.1);
1689 expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x3, epsilon: epsilon));
1690
1691 await tester.pump(duration * 0.1);
1692 expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x2, epsilon: epsilon));
1693
1694 await tester.pump(duration * 0.1);
1695 expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x1, epsilon: epsilon));
1696
1697 await tester.pump(duration * 0.1);
1698 expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x0, epsilon: epsilon));
1699
1700 // Below: show that a different pop Hero path is in fact taken after
1701 // a completed push transition.
1702
1703 // Complete the pop transition and we're back to showing /.
1704 await tester.pumpAndSettle();
1705 expect(tester.getTopLeft(find.byKey(firstKey)).dx, 4.0); // Card contents are inset by 4.0.
1706
1707 // Push /twoInset and wait for the transition to finish.
1708 await tester.tap(find.text('twoInset'));
1709 await tester.pumpAndSettle();
1710 expect(tester.getTopLeft(find.byKey(secondKey)).dx, 54.0);
1711
1712 // Start the pop transition from /twoInset to /.
1713 await tester.tap(find.text('pop'));
1714 await tester.pump();
1715
1716 // Now the firstKey widget is the flying hero widget and it starts
1717 // out lined up with the secondKey widget.
1718 await tester.pump();
1719 expect(tester.getTopLeft(find.byKey(firstKey)).dx, 54.0);
1720
1721 // x0-x4 are the top left x coordinates for the beginning 40% of
1722 // the incoming flight. Advance the outgoing flight to the same
1723 // place.
1724 await tester.pump(duration * 0.6);
1725
1726 await tester.pump(duration * 0.1);
1727 expect(
1728 tester.getTopLeft(find.byKey(firstKey)).dx,
1729 isNot(moreOrLessEquals(x4, epsilon: epsilon)),
1730 );
1731
1732 await tester.pump(duration * 0.1);
1733 expect(
1734 tester.getTopLeft(find.byKey(firstKey)).dx,
1735 isNot(moreOrLessEquals(x3, epsilon: epsilon)),
1736 );
1737
1738 // At this point the flight path arcs do start to get pretty close so
1739 // there's no point in comparing them.
1740 await tester.pump(duration * 0.1);
1741
1742 // After the remaining 40% of the incoming flight is complete, we
1743 // expect to end up where the outgoing flight started.
1744 await tester.pump(duration * 0.1);
1745 expect(tester.getTopLeft(find.byKey(firstKey)).dx, x0);
1746 });
1747
1748 testWidgets('Can override flight shuttle in to hero', (WidgetTester tester) async {
1749 await tester.pumpWidget(
1750 MaterialApp(
1751 home: Material(
1752 child: ListView(
1753 children: <Widget>[
1754 const Hero(tag: 'a', child: Text('foo')),
1755 Builder(
1756 builder: (BuildContext context) {
1757 return TextButton(
1758 child: const Text('two'),
1759 onPressed:
1760 () => Navigator.push<void>(
1761 context,
1762 MaterialPageRoute<void>(
1763 builder: (BuildContext context) {
1764 return Material(
1765 child: Hero(
1766 tag: 'a',
1767 child: const Text('bar'),
1768 flightShuttleBuilder: (
1769 BuildContext flightContext,
1770 Animation<double> animation,
1771 HeroFlightDirection flightDirection,
1772 BuildContext fromHeroContext,
1773 BuildContext toHeroContext,
1774 ) {
1775 return const Text('baz');
1776 },
1777 ),
1778 );
1779 },
1780 ),
1781 ),
1782 );
1783 },
1784 ),
1785 ],
1786 ),
1787 ),
1788 ),
1789 );
1790
1791 await tester.tap(find.text('two'));
1792 await tester.pump();
1793 await tester.pump(const Duration(milliseconds: 10));
1794
1795 expect(find.text('foo'), findsNothing);
1796 expect(find.text('bar'), findsNothing);
1797 expect(find.text('baz'), findsOneWidget);
1798 });
1799
1800 testWidgets('Can override flight shuttle in from hero', (WidgetTester tester) async {
1801 await tester.pumpWidget(
1802 MaterialApp(
1803 home: Material(
1804 child: ListView(
1805 children: <Widget>[
1806 Hero(
1807 tag: 'a',
1808 child: const Text('foo'),
1809 flightShuttleBuilder: (
1810 BuildContext flightContext,
1811 Animation<double> animation,
1812 HeroFlightDirection flightDirection,
1813 BuildContext fromHeroContext,
1814 BuildContext toHeroContext,
1815 ) {
1816 return const Text('baz');
1817 },
1818 ),
1819 Builder(
1820 builder: (BuildContext context) {
1821 return TextButton(
1822 child: const Text('two'),
1823 onPressed:
1824 () => Navigator.push<void>(
1825 context,
1826 MaterialPageRoute<void>(
1827 builder: (BuildContext context) {
1828 return const Material(child: Hero(tag: 'a', child: Text('bar')));
1829 },
1830 ),
1831 ),
1832 );
1833 },
1834 ),
1835 ],
1836 ),
1837 ),
1838 ),
1839 );
1840
1841 await tester.tap(find.text('two'));
1842 await tester.pump();
1843 await tester.pump(const Duration(milliseconds: 10));
1844
1845 expect(find.text('foo'), findsNothing);
1846 expect(find.text('bar'), findsNothing);
1847 expect(find.text('baz'), findsOneWidget);
1848 });
1849
1850 // Regression test for https://github.com/flutter/flutter/issues/77720.
1851 testWidgets("toHero's shuttle builder over fromHero's shuttle builder", (
1852 WidgetTester tester,
1853 ) async {
1854 await tester.pumpWidget(
1855 MaterialApp(
1856 home: Material(
1857 child: ListView(
1858 children: <Widget>[
1859 Hero(
1860 tag: 'a',
1861 child: const Text('foo'),
1862 flightShuttleBuilder: (
1863 BuildContext flightContext,
1864 Animation<double> animation,
1865 HeroFlightDirection flightDirection,
1866 BuildContext fromHeroContext,
1867 BuildContext toHeroContext,
1868 ) {
1869 return const Text('fromHero text');
1870 },
1871 ),
1872 Builder(
1873 builder: (BuildContext context) {
1874 return TextButton(
1875 child: const Text('two'),
1876 onPressed:
1877 () => Navigator.push<void>(
1878 context,
1879 MaterialPageRoute<void>(
1880 builder: (BuildContext context) {
1881 return Material(
1882 child: Hero(
1883 tag: 'a',
1884 child: const Text('bar'),
1885 flightShuttleBuilder: (
1886 BuildContext flightContext,
1887 Animation<double> animation,
1888 HeroFlightDirection flightDirection,
1889 BuildContext fromHeroContext,
1890 BuildContext toHeroContext,
1891 ) {
1892 return const Text('toHero text');
1893 },
1894 ),
1895 );
1896 },
1897 ),
1898 ),
1899 );
1900 },
1901 ),
1902 ],
1903 ),
1904 ),
1905 ),
1906 );
1907
1908 await tester.tap(find.text('two'));
1909 await tester.pump();
1910 await tester.pump(const Duration(milliseconds: 10));
1911
1912 expect(find.text('foo'), findsNothing);
1913 expect(find.text('bar'), findsNothing);
1914 expect(find.text('fromHero text'), findsNothing);
1915 expect(find.text('toHero text'), findsOneWidget);
1916 });
1917
1918 testWidgets('Can override flight launch pads', (WidgetTester tester) async {
1919 await tester.pumpWidget(
1920 MaterialApp(
1921 home: Material(
1922 child: ListView(
1923 children: <Widget>[
1924 Hero(
1925 tag: 'a',
1926 child: const Text('Batman'),
1927 placeholderBuilder: (BuildContext context, Size heroSize, Widget child) {
1928 return const Text('Venom');
1929 },
1930 ),
1931 Builder(
1932 builder: (BuildContext context) {
1933 return TextButton(
1934 child: const Text('two'),
1935 onPressed:
1936 () => Navigator.push<void>(
1937 context,
1938 MaterialPageRoute<void>(
1939 builder: (BuildContext context) {
1940 return Material(
1941 child: Hero(
1942 tag: 'a',
1943 child: const Text('Wolverine'),
1944 placeholderBuilder: (
1945 BuildContext context,
1946 Size size,
1947 Widget child,
1948 ) {
1949 return const Text('Joker');
1950 },
1951 ),
1952 );
1953 },
1954 ),
1955 ),
1956 );
1957 },
1958 ),
1959 ],
1960 ),
1961 ),
1962 ),
1963 );
1964
1965 await tester.tap(find.text('two'));
1966 await tester.pump();
1967 await tester.pump(const Duration(milliseconds: 10));
1968
1969 expect(find.text('Batman'), findsNothing);
1970 // This shows up once but in the Hero because by default, the destination
1971 // Hero child is the widget in flight.
1972 expect(find.text('Wolverine'), findsOneWidget);
1973 expect(find.text('Venom'), findsOneWidget);
1974 expect(find.text('Joker'), findsOneWidget);
1975 });
1976
1977 testWidgets(
1978 'Heroes do not transition on back gestures by default',
1979 (WidgetTester tester) async {
1980 await tester.pumpWidget(MaterialApp(routes: routes));
1981
1982 expect(find.byKey(firstKey), isOnstage);
1983 expect(find.byKey(firstKey), isInCard);
1984 expect(find.byKey(secondKey), findsNothing);
1985
1986 await tester.tap(find.text('two'));
1987 await tester.pump();
1988 await tester.pump(const Duration(milliseconds: 501));
1989
1990 expect(find.byKey(firstKey), findsNothing);
1991 expect(find.byKey(secondKey), isOnstage);
1992 expect(find.byKey(secondKey), isInCard);
1993
1994 final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0));
1995 await gesture.moveBy(const Offset(20.0, 0.0));
1996 await gesture.moveBy(const Offset(180.0, 0.0));
1997 await gesture.up();
1998 await tester.pump();
1999
2000 await tester.pump();
2001
2002 // Both Heroes exist and are seated in their normal parents.
2003 expect(find.byKey(firstKey), isOnstage);
2004 expect(find.byKey(firstKey), isInCard);
2005 expect(find.byKey(secondKey), isOnstage);
2006 expect(find.byKey(secondKey), isInCard);
2007
2008 // To make sure the hero had all chances of starting.
2009 await tester.pump(const Duration(milliseconds: 100));
2010 expect(find.byKey(firstKey), isOnstage);
2011 expect(find.byKey(firstKey), isInCard);
2012 expect(find.byKey(secondKey), isOnstage);
2013 expect(find.byKey(secondKey), isInCard);
2014 },
2015 variant: const TargetPlatformVariant(<TargetPlatform>{
2016 TargetPlatform.iOS,
2017 TargetPlatform.macOS,
2018 }),
2019 );
2020
2021 testWidgets(
2022 'Heroes can transition on gesture in one frame',
2023 (WidgetTester tester) async {
2024 transitionFromUserGestures = true;
2025 await tester.pumpWidget(MaterialApp(routes: routes));
2026
2027 await tester.tap(find.text('two'));
2028 await tester.pump();
2029 await tester.pump(const Duration(milliseconds: 501));
2030
2031 expect(find.byKey(firstKey), findsNothing);
2032 expect(find.byKey(secondKey), isOnstage);
2033 expect(find.byKey(secondKey), isInCard);
2034
2035 final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0));
2036 await gesture.moveBy(const Offset(200.0, 0.0));
2037 await tester.pump();
2038
2039 // We're going to page 1 so page 1's Hero is lifted into flight.
2040 expect(find.byKey(firstKey), isOnstage);
2041 expect(find.byKey(firstKey), isNotInCard);
2042 expect(find.byKey(secondKey), findsNothing);
2043
2044 // Move further along.
2045 await gesture.moveBy(const Offset(500.0, 0.0));
2046 await tester.pump();
2047
2048 // Same results.
2049 expect(find.byKey(firstKey), isOnstage);
2050 expect(find.byKey(firstKey), isNotInCard);
2051 expect(find.byKey(secondKey), findsNothing);
2052
2053 await gesture.up();
2054 // Finish transition.
2055 await tester.pump();
2056 await tester.pump(const Duration(milliseconds: 500));
2057
2058 // Hero A is back in the card.
2059 expect(find.byKey(firstKey), isOnstage);
2060 expect(find.byKey(firstKey), isInCard);
2061 expect(find.byKey(secondKey), findsNothing);
2062 },
2063 variant: const TargetPlatformVariant(<TargetPlatform>{
2064 TargetPlatform.iOS,
2065 TargetPlatform.macOS,
2066 }),
2067 );
2068
2069 testWidgets(
2070 'Heroes animate should hide destination hero and display original hero in case of dismissed',
2071 (WidgetTester tester) async {
2072 transitionFromUserGestures = true;
2073 await tester.pumpWidget(MaterialApp(routes: routes));
2074
2075 await tester.tap(find.text('two'));
2076 await tester.pumpAndSettle();
2077
2078 expect(find.byKey(firstKey), findsNothing);
2079 expect(find.byKey(secondKey), isOnstage);
2080 expect(find.byKey(secondKey), isInCard);
2081
2082 final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0));
2083 await gesture.moveBy(const Offset(50.0, 0.0));
2084 await tester.pump();
2085 // It will only register the drag if we move a second time.
2086 await gesture.moveBy(const Offset(50.0, 0.0));
2087 await tester.pump();
2088
2089 // We're going to page 1 so page 1's Hero is lifted into flight.
2090 expect(find.byKey(firstKey), isOnstage);
2091 expect(find.byKey(firstKey), isNotInCard);
2092 expect(find.byKey(secondKey), findsNothing);
2093
2094 // Dismisses hero transition.
2095 await gesture.up();
2096 await tester.pump();
2097 await tester.pumpAndSettle();
2098
2099 // We goes back to second page.
2100 expect(find.byKey(firstKey), findsNothing);
2101 expect(find.byKey(secondKey), isOnstage);
2102 expect(find.byKey(secondKey), isInCard);
2103 },
2104 variant: const TargetPlatformVariant(<TargetPlatform>{
2105 TargetPlatform.iOS,
2106 TargetPlatform.macOS,
2107 }),
2108 );
2109
2110 testWidgets('Handles transitions when a non-default initial route is set', (
2111 WidgetTester tester,
2112 ) async {
2113 await tester.pumpWidget(MaterialApp(routes: routes, initialRoute: '/two'));
2114 expect(tester.takeException(), isNull);
2115 expect(find.text('two'), findsNothing);
2116 expect(find.text('three'), findsOneWidget);
2117 });
2118
2119 testWidgets('Can push/pop on outer Navigator if nested Navigator contains Heroes', (
2120 WidgetTester tester,
2121 ) async {
2122 // Regression test for https://github.com/flutter/flutter/issues/28042.
2123
2124 const String heroTag = 'You are my hero!';
2125 final GlobalKey<NavigatorState> rootNavigator = GlobalKey();
2126 final GlobalKey<NavigatorState> nestedNavigator = GlobalKey();
2127 final Key nestedRouteHeroBottom = UniqueKey();
2128 final Key nestedRouteHeroTop = UniqueKey();
2129
2130 await tester.pumpWidget(
2131 MaterialApp(
2132 navigatorKey: rootNavigator,
2133 home: Navigator(
2134 key: nestedNavigator,
2135 onGenerateRoute: (RouteSettings settings) {
2136 return MaterialPageRoute<void>(
2137 builder: (BuildContext context) {
2138 return Hero(tag: heroTag, child: Placeholder(key: nestedRouteHeroBottom));
2139 },
2140 );
2141 },
2142 ),
2143 ),
2144 );
2145
2146 nestedNavigator.currentState!.push(
2147 MaterialPageRoute<void>(
2148 builder: (BuildContext context) {
2149 return Hero(tag: heroTag, child: Placeholder(key: nestedRouteHeroTop));
2150 },
2151 ),
2152 );
2153 await tester.pumpAndSettle();
2154
2155 // Both heroes are in the tree, one is offstage
2156 expect(find.byKey(nestedRouteHeroTop), findsOneWidget);
2157 expect(find.byKey(nestedRouteHeroBottom), findsNothing);
2158 expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget);
2159
2160 rootNavigator.currentState!.push(
2161 MaterialPageRoute<void>(
2162 builder: (BuildContext context) {
2163 return const Text('Foo');
2164 },
2165 ),
2166 );
2167 await tester.pumpAndSettle();
2168
2169 expect(find.text('Foo'), findsOneWidget);
2170 // Both heroes are still in the tree, both are offstage.
2171 expect(find.byKey(nestedRouteHeroBottom), findsNothing);
2172 expect(find.byKey(nestedRouteHeroTop), findsNothing);
2173 expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget);
2174 expect(find.byKey(nestedRouteHeroTop, skipOffstage: false), findsOneWidget);
2175
2176 // Doesn't crash.
2177 expect(tester.takeException(), isNull);
2178
2179 rootNavigator.currentState!.pop();
2180 await tester.pumpAndSettle();
2181
2182 expect(find.text('Foo'), findsNothing);
2183 // Both heroes are in the tree, one is offstage
2184 expect(find.byKey(nestedRouteHeroTop), findsOneWidget);
2185 expect(find.byKey(nestedRouteHeroBottom), findsNothing);
2186 expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget);
2187 });
2188
2189 testWidgets('Can hero from route in root Navigator to route in nested Navigator', (
2190 WidgetTester tester,
2191 ) async {
2192 const String heroTag = 'foo';
2193 final GlobalKey<NavigatorState> rootNavigator = GlobalKey();
2194 final Key smallContainer = UniqueKey();
2195 final Key largeContainer = UniqueKey();
2196
2197 await tester.pumpWidget(
2198 MaterialApp(
2199 navigatorKey: rootNavigator,
2200 home: Center(
2201 child: Card(
2202 child: Hero(
2203 tag: heroTag,
2204 child: Container(key: largeContainer, color: Colors.red, height: 200.0, width: 200.0),
2205 ),
2206 ),
2207 ),
2208 ),
2209 );
2210
2211 // The initial setup.
2212 expect(find.byKey(largeContainer), isOnstage);
2213 expect(find.byKey(largeContainer), isInCard);
2214 expect(find.byKey(smallContainer, skipOffstage: false), findsNothing);
2215
2216 rootNavigator.currentState!.push(
2217 MaterialPageRoute<void>(
2218 builder: (BuildContext context) {
2219 return Center(
2220 child: Card(
2221 child: Hero(
2222 tag: heroTag,
2223 child: Container(
2224 key: smallContainer,
2225 color: Colors.red,
2226 height: 100.0,
2227 width: 100.0,
2228 ),
2229 ),
2230 ),
2231 );
2232 },
2233 ),
2234 );
2235 await tester.pump();
2236
2237 // The second route exists offstage.
2238 expect(find.byKey(largeContainer), isOnstage);
2239 expect(find.byKey(largeContainer), isInCard);
2240 expect(find.byKey(smallContainer, skipOffstage: false), isOffstage);
2241 expect(find.byKey(smallContainer, skipOffstage: false), isInCard);
2242
2243 await tester.pump();
2244
2245 // The hero started flying.
2246 expect(find.byKey(largeContainer), findsNothing);
2247 expect(find.byKey(smallContainer), isOnstage);
2248 expect(find.byKey(smallContainer), isNotInCard);
2249
2250 await tester.pump(const Duration(milliseconds: 100));
2251
2252 // The hero is in-flight.
2253 expect(find.byKey(largeContainer), findsNothing);
2254 expect(find.byKey(smallContainer), isOnstage);
2255 expect(find.byKey(smallContainer), isNotInCard);
2256 final Size size = tester.getSize(find.byKey(smallContainer));
2257 expect(size.height, greaterThan(100));
2258 expect(size.width, greaterThan(100));
2259 expect(size.height, lessThan(200));
2260 expect(size.width, lessThan(200));
2261
2262 await tester.pumpAndSettle();
2263
2264 // The transition has ended.
2265 expect(find.byKey(largeContainer), findsNothing);
2266 expect(find.byKey(smallContainer), isOnstage);
2267 expect(find.byKey(smallContainer), isInCard);
2268 expect(tester.getSize(find.byKey(smallContainer)), const Size(100, 100));
2269 });
2270
2271 testWidgets('Hero within a Hero, throws', (WidgetTester tester) async {
2272 await tester.pumpWidget(
2273 const MaterialApp(
2274 home: Material(
2275 child: Hero(tag: 'a', child: Hero(tag: 'b', child: Text('Child of a Hero'))),
2276 ),
2277 ),
2278 );
2279
2280 expect(tester.takeException(), isAssertionError);
2281 });
2282
2283 testWidgets('Can push/pop on outer Navigator if nested Navigators contains same Heroes', (
2284 WidgetTester tester,
2285 ) async {
2286 const String heroTag = 'foo';
2287 final GlobalKey<NavigatorState> rootNavigator = GlobalKey<NavigatorState>();
2288 final Key rootRouteHero = UniqueKey();
2289 final Key nestedRouteHeroOne = UniqueKey();
2290 final Key nestedRouteHeroTwo = UniqueKey();
2291 final List<Key> keys = <Key>[nestedRouteHeroOne, nestedRouteHeroTwo];
2292
2293 await tester.pumpWidget(
2294 CupertinoApp(
2295 navigatorKey: rootNavigator,
2296 home: CupertinoTabScaffold(
2297 tabBar: CupertinoTabBar(
2298 items: const <BottomNavigationBarItem>[
2299 BottomNavigationBarItem(icon: Icon(Icons.home)),
2300 BottomNavigationBarItem(icon: Icon(Icons.favorite)),
2301 ],
2302 ),
2303 tabBuilder: (BuildContext context, int index) {
2304 return CupertinoTabView(
2305 builder:
2306 (BuildContext context) =>
2307 Hero(tag: heroTag, child: Placeholder(key: keys[index])),
2308 );
2309 },
2310 ),
2311 ),
2312 );
2313
2314 // Show both tabs to init.
2315 await tester.tap(find.byIcon(Icons.home));
2316 await tester.pump();
2317
2318 await tester.tap(find.byIcon(Icons.favorite));
2319 await tester.pump();
2320
2321 // Inner heroes are in the tree, one is offstage.
2322 expect(find.byKey(nestedRouteHeroTwo), findsOneWidget);
2323 expect(find.byKey(nestedRouteHeroOne), findsNothing);
2324 expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget);
2325
2326 // Root hero is not in the tree.
2327 expect(find.byKey(rootRouteHero), findsNothing);
2328
2329 rootNavigator.currentState!.push(
2330 MaterialPageRoute<void>(
2331 builder:
2332 (BuildContext context) => Hero(tag: heroTag, child: Placeholder(key: rootRouteHero)),
2333 ),
2334 );
2335
2336 await tester.pumpAndSettle();
2337
2338 // Inner heroes are still in the tree, both are offstage.
2339 expect(find.byKey(nestedRouteHeroOne), findsNothing);
2340 expect(find.byKey(nestedRouteHeroTwo), findsNothing);
2341 expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget);
2342 expect(find.byKey(nestedRouteHeroTwo, skipOffstage: false), findsOneWidget);
2343
2344 // Root hero is in the tree.
2345 expect(find.byKey(rootRouteHero), findsOneWidget);
2346
2347 // Doesn't crash.
2348 expect(tester.takeException(), isNull);
2349
2350 rootNavigator.currentState!.pop();
2351 await tester.pumpAndSettle();
2352
2353 // Root hero is not in the tree
2354 expect(find.byKey(rootRouteHero), findsNothing);
2355
2356 // Both heroes are in the tree, one is offstage
2357 expect(find.byKey(nestedRouteHeroTwo), findsOneWidget);
2358 expect(find.byKey(nestedRouteHeroOne), findsNothing);
2359 expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget);
2360 });
2361
2362 testWidgets('Hero within a Hero subtree, throws', (WidgetTester tester) async {
2363 await tester.pumpWidget(
2364 const MaterialApp(
2365 home: Material(
2366 child: Hero(tag: 'a', child: Hero(tag: 'b', child: Text('Child of a Hero'))),
2367 ),
2368 ),
2369 );
2370
2371 expect(tester.takeException(), isAssertionError);
2372 });
2373
2374 testWidgets('Hero within a Hero subtree with Builder, throws', (WidgetTester tester) async {
2375 await tester.pumpWidget(
2376 MaterialApp(
2377 home: Material(
2378 child: Hero(
2379 tag: 'a',
2380 child: Builder(
2381 builder: (BuildContext context) {
2382 return const Hero(tag: 'b', child: Text('Child of a Hero'));
2383 },
2384 ),
2385 ),
2386 ),
2387 ),
2388 );
2389
2390 expect(tester.takeException(), isAssertionError);
2391 });
2392
2393 testWidgets('Hero within a Hero subtree with LayoutBuilder, throws', (WidgetTester tester) async {
2394 await tester.pumpWidget(
2395 MaterialApp(
2396 home: Material(
2397 child: Hero(
2398 tag: 'a',
2399 child: LayoutBuilder(
2400 builder: (BuildContext context, BoxConstraints constraints) {
2401 return const Hero(tag: 'b', child: Text('Child of a Hero'));
2402 },
2403 ),
2404 ),
2405 ),
2406 ),
2407 );
2408
2409 expect(tester.takeException(), isAssertionError);
2410 });
2411
2412 testWidgets('Heroes fly on pushReplacement', (WidgetTester tester) async {
2413 // Regression test for https://github.com/flutter/flutter/issues/28041.
2414
2415 const String heroTag = 'foo';
2416 final GlobalKey<NavigatorState> navigator = GlobalKey();
2417 final Key smallContainer = UniqueKey();
2418 final Key largeContainer = UniqueKey();
2419
2420 await tester.pumpWidget(
2421 MaterialApp(
2422 navigatorKey: navigator,
2423 home: Center(
2424 child: Card(
2425 child: Hero(
2426 tag: heroTag,
2427 child: Container(key: largeContainer, color: Colors.red, height: 200.0, width: 200.0),
2428 ),
2429 ),
2430 ),
2431 ),
2432 );
2433
2434 // The initial setup.
2435 expect(find.byKey(largeContainer), isOnstage);
2436 expect(find.byKey(largeContainer), isInCard);
2437 expect(find.byKey(smallContainer, skipOffstage: false), findsNothing);
2438
2439 navigator.currentState!.pushReplacement(
2440 MaterialPageRoute<void>(
2441 builder: (BuildContext context) {
2442 return Center(
2443 child: Card(
2444 child: Hero(
2445 tag: heroTag,
2446 child: Container(
2447 key: smallContainer,
2448 color: Colors.red,
2449 height: 100.0,
2450 width: 100.0,
2451 ),
2452 ),
2453 ),
2454 );
2455 },
2456 ),
2457 );
2458 await tester.pump();
2459
2460 // The second route exists offstage.
2461 expect(find.byKey(largeContainer), isOnstage);
2462 expect(find.byKey(largeContainer), isInCard);
2463 expect(find.byKey(smallContainer, skipOffstage: false), isOffstage);
2464 expect(find.byKey(smallContainer, skipOffstage: false), isInCard);
2465
2466 await tester.pump();
2467
2468 // The hero started flying.
2469 expect(find.byKey(largeContainer), findsNothing);
2470 expect(find.byKey(smallContainer), isOnstage);
2471 expect(find.byKey(smallContainer), isNotInCard);
2472
2473 await tester.pump(const Duration(milliseconds: 100));
2474
2475 // The hero is in-flight.
2476 expect(find.byKey(largeContainer), findsNothing);
2477 expect(find.byKey(smallContainer), isOnstage);
2478 expect(find.byKey(smallContainer), isNotInCard);
2479 final Size size = tester.getSize(find.byKey(smallContainer));
2480 expect(size.height, greaterThan(100));
2481 expect(size.width, greaterThan(100));
2482 expect(size.height, lessThan(200));
2483 expect(size.width, lessThan(200));
2484
2485 await tester.pumpAndSettle();
2486
2487 // The transition has ended.
2488 expect(find.byKey(largeContainer), findsNothing);
2489 expect(find.byKey(smallContainer), isOnstage);
2490 expect(find.byKey(smallContainer), isInCard);
2491 expect(tester.getSize(find.byKey(smallContainer)), const Size(100, 100));
2492 });
2493
2494 testWidgets('Can add two page with heroes simultaneously using page API.', (
2495 WidgetTester tester,
2496 ) async {
2497 // Regression test for https://github.com/flutter/flutter/issues/115358.
2498
2499 const String heroTag = 'foo';
2500 final GlobalKey<NavigatorState> navigator = GlobalKey();
2501 final Key smallContainer = UniqueKey();
2502 final Key largeContainer = UniqueKey();
2503 final MaterialPage<void> page1 = MaterialPage<void>(
2504 child: Center(
2505 child: Card(
2506 child: Hero(
2507 tag: heroTag,
2508 child: Container(key: largeContainer, color: Colors.red, height: 200.0, width: 200.0),
2509 ),
2510 ),
2511 ),
2512 );
2513 final MaterialPage<void> page2 = MaterialPage<void>(
2514 child: Center(
2515 child: Card(
2516 child: Hero(
2517 tag: heroTag,
2518 child: Container(color: Colors.red, height: 1000.0, width: 1000.0),
2519 ),
2520 ),
2521 ),
2522 );
2523 final MaterialPage<void> page3 = MaterialPage<void>(
2524 child: Center(
2525 child: Card(
2526 child: Hero(
2527 tag: heroTag,
2528 child: Container(key: smallContainer, color: Colors.red, height: 100.0, width: 100.0),
2529 ),
2530 ),
2531 ),
2532 );
2533 final HeroController controller = HeroController();
2534 addTearDown(controller.dispose);
2535
2536 await tester.pumpWidget(
2537 MaterialApp(
2538 navigatorKey: navigator,
2539 home: Navigator(
2540 observers: <NavigatorObserver>[controller],
2541 pages: <Page<void>>[page1],
2542 onPopPage: (_, _) => false,
2543 ),
2544 ),
2545 );
2546
2547 // The initial setup.
2548 expect(find.byKey(largeContainer), isOnstage);
2549 expect(find.byKey(largeContainer), isInCard);
2550 expect(find.byKey(smallContainer, skipOffstage: false), findsNothing);
2551
2552 await tester.pumpWidget(
2553 MaterialApp(
2554 navigatorKey: navigator,
2555 home: Navigator(
2556 observers: <NavigatorObserver>[controller],
2557 pages: <Page<void>>[page1, page2, page3],
2558 onPopPage: (_, _) => false,
2559 ),
2560 ),
2561 );
2562
2563 expect(find.byKey(largeContainer), isOnstage);
2564 expect(find.byKey(largeContainer), isInCard);
2565 expect(find.byKey(smallContainer, skipOffstage: false), isOffstage);
2566 expect(find.byKey(smallContainer, skipOffstage: false), isInCard);
2567
2568 await tester.pump();
2569
2570 // The hero started flying.
2571 expect(find.byKey(largeContainer), findsNothing);
2572 expect(find.byKey(smallContainer), isOnstage);
2573 expect(find.byKey(smallContainer), isNotInCard);
2574
2575 await tester.pump(const Duration(milliseconds: 100));
2576
2577 // The hero is in-flight.
2578 expect(find.byKey(largeContainer), findsNothing);
2579 expect(find.byKey(smallContainer), isOnstage);
2580 expect(find.byKey(smallContainer), isNotInCard);
2581 final Size size = tester.getSize(find.byKey(smallContainer));
2582 expect(size.height, greaterThan(100));
2583 expect(size.width, greaterThan(100));
2584 expect(size.height, lessThan(200));
2585 expect(size.width, lessThan(200));
2586
2587 await tester.pumpAndSettle();
2588
2589 // The transition has ended.
2590 expect(find.byKey(largeContainer), findsNothing);
2591 expect(find.byKey(smallContainer), isOnstage);
2592 expect(find.byKey(smallContainer), isInCard);
2593 expect(tester.getSize(find.byKey(smallContainer)), const Size(100, 100));
2594 });
2595
2596 testWidgets('Can still trigger hero even if page underneath changes', (
2597 WidgetTester tester,
2598 ) async {
2599 // Regression test for https://github.com/flutter/flutter/issues/88578.
2600
2601 const String heroTag = 'foo';
2602 final GlobalKey<NavigatorState> navigator = GlobalKey();
2603 final Key smallContainer = UniqueKey();
2604 final Key largeContainer = UniqueKey();
2605 final MaterialPage<void> unrelatedPage1 = MaterialPage<void>(
2606 key: UniqueKey(),
2607 child: Center(
2608 child: Card(child: Container(color: Colors.red, height: 1000.0, width: 1000.0)),
2609 ),
2610 );
2611 final MaterialPage<void> unrelatedPage2 = MaterialPage<void>(
2612 key: UniqueKey(),
2613 child: Center(
2614 child: Card(child: Container(color: Colors.red, height: 1000.0, width: 1000.0)),
2615 ),
2616 );
2617 final MaterialPage<void> page1 = MaterialPage<void>(
2618 key: UniqueKey(),
2619 child: Center(
2620 child: Card(
2621 child: Hero(
2622 tag: heroTag,
2623 child: Container(key: largeContainer, color: Colors.red, height: 200.0, width: 200.0),
2624 ),
2625 ),
2626 ),
2627 );
2628 final MaterialPage<void> page2 = MaterialPage<void>(
2629 key: UniqueKey(),
2630 child: Center(
2631 child: Card(
2632 child: Hero(
2633 tag: heroTag,
2634 child: Container(key: smallContainer, color: Colors.red, height: 100.0, width: 100.0),
2635 ),
2636 ),
2637 ),
2638 );
2639 final HeroController controller = HeroController();
2640 addTearDown(controller.dispose);
2641
2642 await tester.pumpWidget(
2643 MaterialApp(
2644 navigatorKey: navigator,
2645 home: Navigator(
2646 observers: <NavigatorObserver>[controller],
2647 pages: <Page<void>>[unrelatedPage1, page1],
2648 onPopPage: (_, _) => false,
2649 ),
2650 ),
2651 );
2652
2653 // The initial setup.
2654 expect(find.byKey(largeContainer), isOnstage);
2655 expect(find.byKey(largeContainer), isInCard);
2656 expect(find.byKey(smallContainer, skipOffstage: false), findsNothing);
2657
2658 await tester.pumpWidget(
2659 MaterialApp(
2660 navigatorKey: navigator,
2661 home: Navigator(
2662 observers: <NavigatorObserver>[controller],
2663 pages: <Page<void>>[unrelatedPage2, page2],
2664 onPopPage: (_, _) => false,
2665 ),
2666 ),
2667 );
2668
2669 expect(find.byKey(largeContainer), isOnstage);
2670 expect(find.byKey(largeContainer), isInCard);
2671 expect(find.byKey(smallContainer, skipOffstage: false), isOffstage);
2672 expect(find.byKey(smallContainer, skipOffstage: false), isInCard);
2673
2674 await tester.pump();
2675
2676 // The hero started flying.
2677 expect(find.byKey(largeContainer), findsNothing);
2678 expect(find.byKey(smallContainer), isOnstage);
2679 expect(find.byKey(smallContainer), isNotInCard);
2680
2681 await tester.pump(const Duration(milliseconds: 100));
2682
2683 // The hero is in-flight.
2684 expect(find.byKey(largeContainer), findsNothing);
2685 expect(find.byKey(smallContainer), isOnstage);
2686 expect(find.byKey(smallContainer), isNotInCard);
2687 final Size size = tester.getSize(find.byKey(smallContainer));
2688 expect(size.height, greaterThan(100));
2689 expect(size.width, greaterThan(100));
2690 expect(size.height, lessThan(200));
2691 expect(size.width, lessThan(200));
2692
2693 await tester.pumpAndSettle();
2694
2695 // The transition has ended.
2696 expect(find.byKey(largeContainer), findsNothing);
2697 expect(find.byKey(smallContainer), isOnstage);
2698 expect(find.byKey(smallContainer), isInCard);
2699 expect(tester.getSize(find.byKey(smallContainer)), const Size(100, 100));
2700 });
2701
2702 testWidgets('On an iOS back swipe and snap, only a single flight should take place', (
2703 WidgetTester tester,
2704 ) async {
2705 int shuttlesBuilt = 0;
2706 Widget shuttleBuilder(
2707 BuildContext flightContext,
2708 Animation<double> animation,
2709 HeroFlightDirection flightDirection,
2710 BuildContext fromHeroContext,
2711 BuildContext toHeroContext,
2712 ) {
2713 shuttlesBuilt += 1;
2714 return const Text("I'm flying in a jetplane");
2715 }
2716
2717 final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
2718 await tester.pumpWidget(
2719 CupertinoApp(
2720 navigatorKey: navigatorKey,
2721 home: Hero(
2722 tag: navigatorKey,
2723 // Since we're popping, only the destination route's builder is used.
2724 flightShuttleBuilder: shuttleBuilder,
2725 transitionOnUserGestures: true,
2726 child: const Text('1'),
2727 ),
2728 ),
2729 );
2730
2731 final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
2732 builder: (BuildContext context) {
2733 return CupertinoPageScaffold(
2734 child: Hero(tag: navigatorKey, transitionOnUserGestures: true, child: const Text('2')),
2735 );
2736 },
2737 );
2738
2739 navigatorKey.currentState!.push(route2);
2740 await tester.pumpAndSettle();
2741
2742 expect(shuttlesBuilt, 1);
2743
2744 final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0));
2745 await gesture.moveBy(const Offset(500.0, 0.0));
2746 await tester.pump();
2747 // Starting the back swipe creates a new hero shuttle.
2748 expect(shuttlesBuilt, 2);
2749
2750 await gesture.up();
2751 await tester.pump();
2752 // After the lift, no additional shuttles should be created since it's the
2753 // same hero flight.
2754 expect(shuttlesBuilt, 2);
2755
2756 // Did go far enough to snap out of this route.
2757 await tester.pump(const Duration(milliseconds: 301));
2758 expect(find.text('2'), findsNothing);
2759 // Still one shuttle.
2760 expect(shuttlesBuilt, 2);
2761 });
2762
2763 testWidgets("From hero's state should be preserved, "
2764 'heroes work well with child widgets that has global keys', (WidgetTester tester) async {
2765 final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
2766 final GlobalKey<_SimpleState> key1 = GlobalKey<_SimpleState>();
2767 final GlobalKey key2 = GlobalKey();
2768
2769 await tester.pumpWidget(
2770 CupertinoApp(
2771 navigatorKey: navigatorKey,
2772 home: Row(
2773 crossAxisAlignment: CrossAxisAlignment.start,
2774 children: <Widget>[
2775 Hero(
2776 tag: 'hero',
2777 transitionOnUserGestures: true,
2778 child: _SimpleStatefulWidget(key: key1),
2779 ),
2780 const SizedBox(width: 10, height: 10, child: Text('1')),
2781 ],
2782 ),
2783 ),
2784 );
2785
2786 final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
2787 builder: (BuildContext context) {
2788 return CupertinoPageScaffold(
2789 child: Hero(
2790 tag: 'hero',
2791 transitionOnUserGestures: true,
2792 // key2 is a `GlobalKey`. The hero animation should not
2793 // assert by having the same global keyed widget in more
2794 // than one place in the tree.
2795 child: _SimpleStatefulWidget(key: key2),
2796 ),
2797 );
2798 },
2799 );
2800
2801 final _SimpleState state1 = key1.currentState!;
2802 state1.state = 1;
2803
2804 navigatorKey.currentState!.push(route2);
2805 await tester.pump();
2806
2807 expect(state1.mounted, isTrue);
2808
2809 await tester.pumpAndSettle();
2810 expect(state1.state, 1);
2811 // The element should be mounted and unique.
2812 expect(state1.mounted, isTrue);
2813
2814 navigatorKey.currentState!.pop();
2815 await tester.pumpAndSettle();
2816
2817 // State is preserved.
2818 expect(state1.state, 1);
2819 // The element should be mounted and unique.
2820 expect(state1.mounted, isTrue);
2821 });
2822
2823 testWidgets(
2824 "Hero works with images that don't have both width and height specified",
2825 // Regression test for https://github.com/flutter/flutter/issues/32356
2826 // and https://github.com/flutter/flutter/issues/31503
2827 (WidgetTester tester) async {
2828 final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
2829 const Key imageKey1 = Key('image1');
2830 const Key imageKey2 = Key('image2');
2831 final TestImageProvider imageProvider = TestImageProvider(testImage);
2832
2833 await tester.pumpWidget(
2834 CupertinoApp(
2835 navigatorKey: navigatorKey,
2836 home: Row(
2837 crossAxisAlignment: CrossAxisAlignment.start,
2838 children: <Widget>[
2839 Hero(
2840 tag: 'hero',
2841 transitionOnUserGestures: true,
2842 child: SizedBox(width: 100, child: Image(image: imageProvider, key: imageKey1)),
2843 ),
2844 const SizedBox(width: 10, height: 10, child: Text('1')),
2845 ],
2846 ),
2847 ),
2848 );
2849
2850 final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
2851 builder: (BuildContext context) {
2852 return CupertinoPageScaffold(
2853 child: Hero(
2854 tag: 'hero',
2855 transitionOnUserGestures: true,
2856 child: Image(image: imageProvider, key: imageKey2),
2857 ),
2858 );
2859 },
2860 );
2861
2862 // Load image before measuring the `Rect` of the `RenderImage`.
2863 imageProvider.complete();
2864 await tester.pump();
2865 final RenderImage renderImage = tester.renderObject(
2866 find.descendant(of: find.byKey(imageKey1), matching: find.byType(RawImage)),
2867 );
2868
2869 // Before push image1 should be laid out correctly.
2870 expect(renderImage.size, const Size(100, 100));
2871
2872 navigatorKey.currentState!.push(route2);
2873 await tester.pump();
2874
2875 final TestGesture gesture = await tester.startGesture(const Offset(0.01, 300));
2876 await tester.pump();
2877
2878 // Move (almost) across the screen, to make the animation as close to finish
2879 // as possible.
2880 await gesture.moveTo(const Offset(800, 200));
2881 await tester.pump();
2882
2883 // image1 should snap to the top left corner of the Row widget.
2884 expect(
2885 tester.getRect(find.byKey(imageKey1, skipOffstage: false)),
2886 rectMoreOrLessEquals(
2887 tester.getTopLeft(find.widgetWithText(Row, '1')) & const Size(100, 100),
2888 epsilon: 0.01,
2889 ),
2890 );
2891
2892 // Text should respect the correct final size of image1.
2893 expect(
2894 tester.getTopRight(find.byKey(imageKey1, skipOffstage: false)).dx,
2895 moreOrLessEquals(tester.getTopLeft(find.text('1')).dx, epsilon: 0.01),
2896 );
2897 },
2898 );
2899
2900 // Regression test for https://github.com/flutter/flutter/issues/38183.
2901 testWidgets(
2902 'Remove user gesture driven flights when the gesture is invalid',
2903 (WidgetTester tester) async {
2904 transitionFromUserGestures = true;
2905 await tester.pumpWidget(MaterialApp(routes: routes));
2906
2907 await tester.tap(find.text('simple'));
2908 await tester.pump();
2909 await tester.pumpAndSettle();
2910
2911 expect(find.byKey(simpleKey), findsOneWidget);
2912
2913 // Tap once to trigger a flight.
2914 await tester.tapAt(const Offset(10, 200));
2915 await tester.pumpAndSettle();
2916
2917 // Wait till the previous gesture is accepted.
2918 await tester.pump(const Duration(milliseconds: 500));
2919
2920 // Tap again to trigger another flight, see if it throws.
2921 await tester.tapAt(const Offset(10, 200));
2922 await tester.pumpAndSettle();
2923
2924 // The simple route should still be on top.
2925 expect(find.byKey(simpleKey), findsOneWidget);
2926 expect(tester.takeException(), isNull);
2927 },
2928 variant: const TargetPlatformVariant(<TargetPlatform>{
2929 TargetPlatform.iOS,
2930 TargetPlatform.macOS,
2931 }),
2932 );
2933
2934 // Regression test for https://github.com/flutter/flutter/issues/40239.
2935 testWidgets(
2936 'In a pop transition, when fromHero is null, the to hero should eventually become visible',
2937 (WidgetTester tester) async {
2938 final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
2939 late StateSetter setState;
2940 bool shouldDisplayHero = true;
2941 await tester.pumpWidget(
2942 CupertinoApp(
2943 navigatorKey: navigatorKey,
2944 home: Hero(tag: navigatorKey, child: const Placeholder()),
2945 ),
2946 );
2947
2948 final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
2949 builder: (BuildContext context) {
2950 return CupertinoPageScaffold(
2951 child: StatefulBuilder(
2952 builder: (BuildContext context, StateSetter setter) {
2953 setState = setter;
2954 return shouldDisplayHero
2955 ? Hero(tag: navigatorKey, child: const Text('text'))
2956 : const SizedBox();
2957 },
2958 ),
2959 );
2960 },
2961 );
2962
2963 navigatorKey.currentState!.push(route2);
2964 await tester.pumpAndSettle();
2965
2966 expect(find.text('text'), findsOneWidget);
2967 expect(find.byType(Placeholder), findsNothing);
2968
2969 setState(() {
2970 shouldDisplayHero = false;
2971 });
2972 await tester.pumpAndSettle();
2973
2974 expect(find.text('text'), findsNothing);
2975
2976 navigatorKey.currentState!.pop();
2977 await tester.pumpAndSettle();
2978
2979 expect(find.byType(Placeholder), findsOneWidget);
2980 },
2981 );
2982
2983 testWidgets('popped hero uses fastOutSlowIn curve', (WidgetTester tester) async {
2984 final Key container1 = UniqueKey();
2985 final Key container2 = UniqueKey();
2986 final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>();
2987
2988 final Animatable<Size?> tween = SizeTween(
2989 begin: const Size(200, 200),
2990 end: const Size(100, 100),
2991 ).chain(CurveTween(curve: Curves.fastOutSlowIn));
2992
2993 await tester.pumpWidget(
2994 MaterialApp(
2995 navigatorKey: navigator,
2996 home: Scaffold(
2997 body: Center(
2998 child: Hero(
2999 tag: 'test',
3000 createRectTween: (Rect? begin, Rect? end) {
3001 return RectTween(begin: begin, end: end);
3002 },
3003 child: SizedBox(key: container1, height: 100, width: 100),
3004 ),
3005 ),
3006 ),
3007 ),
3008 );
3009 final Size originalSize = tester.getSize(find.byKey(container1));
3010 expect(originalSize, const Size(100, 100));
3011
3012 navigator.currentState!.push(
3013 MaterialPageRoute<void>(
3014 builder: (BuildContext context) {
3015 return Scaffold(
3016 body: Center(
3017 child: Hero(
3018 tag: 'test',
3019 createRectTween: (Rect? begin, Rect? end) {
3020 return RectTween(begin: begin, end: end);
3021 },
3022 child: SizedBox(key: container2, height: 200, width: 200),
3023 ),
3024 ),
3025 );
3026 },
3027 ),
3028 );
3029 await tester.pumpAndSettle();
3030 final Size newSize = tester.getSize(find.byKey(container2));
3031 expect(newSize, const Size(200, 200));
3032
3033 navigator.currentState!.pop();
3034 await tester.pump();
3035
3036 // Jump 25% into the transition (total length = 300ms)
3037 await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms
3038 Size heroSize = tester.getSize(find.byKey(container1));
3039 expect(heroSize, tween.transform(0.25));
3040
3041 // Jump to 50% into the transition.
3042 await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms
3043 heroSize = tester.getSize(find.byKey(container1));
3044 expect(heroSize, tween.transform(0.50));
3045
3046 // Jump to 75% into the transition.
3047 await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms
3048 heroSize = tester.getSize(find.byKey(container1));
3049 expect(heroSize, tween.transform(0.75));
3050
3051 // Jump to 100% into the transition.
3052 await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms
3053 heroSize = tester.getSize(find.byKey(container1));
3054 expect(heroSize, tween.transform(1.0));
3055 });
3056
3057 testWidgets('Heroes in enabled HeroMode do transition', (WidgetTester tester) async {
3058 await tester.pumpWidget(
3059 MaterialApp(
3060 home: Material(
3061 child: Column(
3062 children: <Widget>[
3063 HeroMode(
3064 child: Card(
3065 child: Hero(
3066 tag: 'a',
3067 child: SizedBox(height: 100.0, width: 100.0, key: firstKey),
3068 ),
3069 ),
3070 ),
3071 Builder(
3072 builder: (BuildContext context) {
3073 return TextButton(
3074 child: const Text('push'),
3075 onPressed: () {
3076 Navigator.push(
3077 context,
3078 PageRouteBuilder<void>(
3079 pageBuilder: (
3080 BuildContext context,
3081 Animation<double> _,
3082 Animation<double> _,
3083 ) {
3084 return Card(
3085 child: Hero(
3086 tag: 'a',
3087 child: SizedBox(height: 150.0, width: 150.0, key: secondKey),
3088 ),
3089 );
3090 },
3091 ),
3092 );
3093 },
3094 );
3095 },
3096 ),
3097 ],
3098 ),
3099 ),
3100 ),
3101 );
3102
3103 expect(find.byKey(firstKey), isOnstage);
3104 expect(find.byKey(firstKey), isInCard);
3105 expect(find.byKey(secondKey), findsNothing);
3106
3107 await tester.tap(find.text('push'));
3108 await tester.pump();
3109
3110 expect(find.byKey(firstKey), isOnstage);
3111 expect(find.byKey(firstKey), isInCard);
3112 expect(find.byKey(secondKey, skipOffstage: false), isOffstage);
3113 expect(find.byKey(secondKey, skipOffstage: false), isInCard);
3114
3115 await tester.pump();
3116
3117 expect(find.byKey(firstKey), findsNothing);
3118 expect(find.byKey(secondKey), findsOneWidget);
3119 expect(find.byKey(secondKey), isNotInCard);
3120 expect(find.byKey(secondKey), isOnstage);
3121
3122 await tester.pump(const Duration(seconds: 1));
3123
3124 expect(find.byKey(firstKey), findsNothing);
3125 expect(find.byKey(secondKey), isOnstage);
3126 expect(find.byKey(secondKey), isInCard);
3127 });
3128
3129 testWidgets('Heroes in disabled HeroMode do not transition', (WidgetTester tester) async {
3130 await tester.pumpWidget(
3131 MaterialApp(
3132 home: Material(
3133 child: Column(
3134 children: <Widget>[
3135 HeroMode(
3136 enabled: false,
3137 child: Card(
3138 child: Hero(
3139 tag: 'a',
3140 child: SizedBox(height: 100.0, width: 100.0, key: firstKey),
3141 ),
3142 ),
3143 ),
3144 Builder(
3145 builder: (BuildContext context) {
3146 return TextButton(
3147 child: const Text('push'),
3148 onPressed: () {
3149 Navigator.push(
3150 context,
3151 PageRouteBuilder<void>(
3152 pageBuilder: (
3153 BuildContext context,
3154 Animation<double> _,
3155 Animation<double> _,
3156 ) {
3157 return Card(
3158 child: Hero(
3159 tag: 'a',
3160 child: SizedBox(height: 150.0, width: 150.0, key: secondKey),
3161 ),
3162 );
3163 },
3164 ),
3165 );
3166 },
3167 );
3168 },
3169 ),
3170 ],
3171 ),
3172 ),
3173 ),
3174 );
3175
3176 expect(find.byKey(firstKey), isOnstage);
3177 expect(find.byKey(firstKey), isInCard);
3178 expect(find.byKey(secondKey), findsNothing);
3179
3180 await tester.tap(find.text('push'));
3181 await tester.pump();
3182
3183 expect(find.byKey(firstKey), isOnstage);
3184 expect(find.byKey(firstKey), isInCard);
3185 expect(find.byKey(secondKey, skipOffstage: false), isOffstage);
3186 expect(find.byKey(secondKey, skipOffstage: false), isInCard);
3187
3188 await tester.pump();
3189
3190 // When HeroMode is disabled, heroes will not move.
3191 // So the original page contains the hero.
3192 expect(find.byKey(firstKey), findsOneWidget);
3193
3194 // The hero should be in the new page, onstage, soon.
3195 expect(find.byKey(secondKey), findsOneWidget);
3196 expect(find.byKey(secondKey), isInCard);
3197 expect(find.byKey(secondKey), isOnstage);
3198
3199 await tester.pump(const Duration(seconds: 1));
3200
3201 expect(find.byKey(firstKey), findsNothing);
3202
3203 expect(find.byKey(secondKey), findsOneWidget);
3204 expect(find.byKey(secondKey), isInCard);
3205 expect(find.byKey(secondKey), isOnstage);
3206 });
3207
3208 testWidgets('kept alive Hero does not throw when the transition begins', (
3209 WidgetTester tester,
3210 ) async {
3211 final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
3212
3213 await tester.pumpWidget(
3214 MaterialApp(
3215 navigatorKey: navigatorKey,
3216 home: Scaffold(
3217 body: ListView(
3218 addAutomaticKeepAlives: false,
3219 addRepaintBoundaries: false,
3220 addSemanticIndexes: false,
3221 children: <Widget>[
3222 const KeepAlive(keepAlive: true, child: Hero(tag: 'a', child: Placeholder())),
3223 Container(height: 1000.0),
3224 ],
3225 ),
3226 ),
3227 ),
3228 );
3229
3230 // Scroll to make the Hero invisible.
3231 await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
3232 await tester.pump();
3233
3234 expect(find.byType(TextField), findsNothing);
3235
3236 navigatorKey.currentState?.push(
3237 MaterialPageRoute<void>(
3238 builder: (BuildContext context) {
3239 return const Scaffold(body: Center(child: Hero(tag: 'a', child: Placeholder())));
3240 },
3241 ),
3242 );
3243 await tester.pumpAndSettle();
3244
3245 expect(tester.takeException(), isNull);
3246 // The Hero on the new route should be visible .
3247 expect(find.byType(Placeholder), findsOneWidget);
3248 });
3249
3250 testWidgets('toHero becomes unpaintable after the transition begins', (
3251 WidgetTester tester,
3252 ) async {
3253 final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
3254 final ScrollController controller = ScrollController();
3255 addTearDown(controller.dispose);
3256
3257 RenderAnimatedOpacity? findRenderAnimatedOpacity() {
3258 RenderObject? parent = tester.renderObject(find.byType(Placeholder));
3259 while (parent is RenderObject && parent is! RenderAnimatedOpacity) {
3260 parent = parent.parent;
3261 }
3262 return parent is RenderAnimatedOpacity ? parent : null;
3263 }
3264
3265 await tester.pumpWidget(
3266 MaterialApp(
3267 navigatorKey: navigatorKey,
3268 home: Scaffold(
3269 body: ListView(
3270 controller: controller,
3271 addAutomaticKeepAlives: false,
3272 addRepaintBoundaries: false,
3273 addSemanticIndexes: false,
3274 children: <Widget>[
3275 const KeepAlive(keepAlive: true, child: Hero(tag: 'a', child: Placeholder())),
3276 Container(height: 1000.0),
3277 ],
3278 ),
3279 ),
3280 ),
3281 );
3282
3283 navigatorKey.currentState?.push(
3284 MaterialPageRoute<void>(
3285 builder: (BuildContext context) {
3286 return const Scaffold(body: Center(child: Hero(tag: 'a', child: Placeholder())));
3287 },
3288 ),
3289 );
3290 await tester.pump();
3291 await tester.pumpAndSettle();
3292
3293 // Pop the new route, and before the animation finishes we scroll the toHero
3294 // to make it unpaintable.
3295 navigatorKey.currentState?.pop();
3296 await tester.pump();
3297 controller.jumpTo(1000);
3298 // Starts Hero animation and scroll animation almost simultaneously.
3299 // Scroll to make the Hero invisible.
3300 await tester.pump();
3301 expect(findRenderAnimatedOpacity()?.opacity.value, anyOf(isNull, 1.0));
3302
3303 // In this frame the Hero animation finds out the toHero is not paintable,
3304 // and starts fading.
3305 await tester.pump();
3306 await tester.pump(const Duration(milliseconds: 100));
3307
3308 expect(findRenderAnimatedOpacity()?.opacity.value, lessThan(1.0));
3309
3310 await tester.pumpAndSettle();
3311 // The Hero on the new route should be invisible.
3312 expect(find.byType(Placeholder), findsNothing);
3313 });
3314
3315 testWidgets('diverting to a keepalive but unpaintable hero', (WidgetTester tester) async {
3316 final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
3317
3318 await tester.pumpWidget(
3319 CupertinoApp(
3320 navigatorKey: navigatorKey,
3321 home: CupertinoPageScaffold(
3322 child: ListView(
3323 addAutomaticKeepAlives: false,
3324 addRepaintBoundaries: false,
3325 addSemanticIndexes: false,
3326 children: <Widget>[
3327 const KeepAlive(keepAlive: true, child: Hero(tag: 'a', child: Placeholder())),
3328 Container(height: 1000.0),
3329 ],
3330 ),
3331 ),
3332 ),
3333 );
3334
3335 // Scroll to make the Hero invisible.
3336 await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
3337 await tester.pump();
3338
3339 expect(find.byType(Placeholder), findsNothing);
3340 expect(find.byType(Placeholder, skipOffstage: false), findsOneWidget);
3341
3342 navigatorKey.currentState?.push(
3343 MaterialPageRoute<void>(
3344 builder: (BuildContext context) {
3345 return const Scaffold(body: Center(child: Hero(tag: 'a', child: Placeholder())));
3346 },
3347 ),
3348 );
3349 await tester.pumpAndSettle();
3350
3351 // Yet another route that contains Hero 'a'.
3352 navigatorKey.currentState?.push(
3353 MaterialPageRoute<void>(
3354 builder: (BuildContext context) {
3355 return const Scaffold(body: Center(child: Hero(tag: 'a', child: Placeholder())));
3356 },
3357 ),
3358 );
3359 await tester.pumpAndSettle();
3360
3361 // Pop both routes.
3362 navigatorKey.currentState?.pop();
3363 await tester.pump();
3364 await tester.pump(const Duration(milliseconds: 10));
3365
3366 navigatorKey.currentState?.pop();
3367 await tester.pump();
3368 await tester.pump(const Duration(milliseconds: 10));
3369 expect(find.byType(Placeholder), findsOneWidget);
3370
3371 await tester.pumpAndSettle();
3372 expect(tester.takeException(), isNull);
3373 });
3374
3375 testWidgets('smooth transition between different incoming data', (WidgetTester tester) async {
3376 addTearDown(tester.view.reset);
3377
3378 final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
3379 const Key imageKey1 = Key('image1');
3380 const Key imageKey2 = Key('image2');
3381 final TestImageProvider imageProvider = TestImageProvider(testImage);
3382
3383 tester.view.padding = const FakeViewPadding(top: 50);
3384
3385 await tester.pumpWidget(
3386 MaterialApp(
3387 navigatorKey: navigatorKey,
3388 home: Scaffold(
3389 appBar: AppBar(title: const Text('test')),
3390 body: Hero(
3391 tag: 'imageHero',
3392 child: GridView.count(
3393 crossAxisCount: 3,
3394 shrinkWrap: true,
3395 children: <Widget>[Image(image: imageProvider, key: imageKey1)],
3396 ),
3397 ),
3398 ),
3399 ),
3400 );
3401
3402 final MaterialPageRoute<void> route2 = MaterialPageRoute<void>(
3403 builder: (BuildContext context) {
3404 return Scaffold(
3405 body: Hero(
3406 tag: 'imageHero',
3407 child: GridView.count(
3408 crossAxisCount: 3,
3409 shrinkWrap: true,
3410 children: <Widget>[Image(image: imageProvider, key: imageKey2)],
3411 ),
3412 ),
3413 );
3414 },
3415 );
3416
3417 // Load images.
3418 imageProvider.complete();
3419 await tester.pump();
3420
3421 final double forwardRest = tester.getTopLeft(find.byType(Image)).dy;
3422 navigatorKey.currentState!.push(route2);
3423 await tester.pump();
3424 await tester.pump(const Duration(milliseconds: 1));
3425 expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1));
3426 await tester.pumpAndSettle();
3427
3428 navigatorKey.currentState!.pop(route2);
3429 await tester.pump();
3430 await tester.pump(const Duration(milliseconds: 300));
3431 expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1));
3432 await tester.pumpAndSettle();
3433 expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1));
3434 });
3435
3436 test('HeroController dispatches memory events', () async {
3437 await expectLater(
3438 await memoryEvents(() => HeroController().dispose(), HeroController),
3439 areCreateAndDispose,
3440 );
3441 });
3442}
3443
3444class TestDependencies extends StatelessWidget {
3445 const TestDependencies({required this.child, super.key});
3446
3447 final Widget child;
3448
3449 @override
3450 Widget build(BuildContext context) {
3451 return Directionality(
3452 textDirection: TextDirection.ltr,
3453 child: MediaQuery(data: MediaQueryData.fromView(View.of(context)), child: child),
3454 );
3455 }
3456}
3457

Provided by KDAB

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