1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
---|---|
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:ui' as ui; |
6 | |
7 | import 'package:flutter/cupertino.dart'; |
8 | import 'package:flutter/foundation.dart'; |
9 | import 'package:flutter/material.dart'; |
10 | import 'package:flutter/rendering.dart'; |
11 | import 'package:flutter_test/flutter_test.dart'; |
12 | import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; |
13 | |
14 | import '../painting/image_test_utils.dart' show TestImageProvider; |
15 | |
16 | Future<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 | |
28 | Key firstKey = const Key('first'); |
29 | Key secondKey = const Key('second'); |
30 | Key thirdKey = const Key('third'); |
31 | Key simpleKey = const Key('simple'); |
32 | |
33 | Key homeRouteKey = const Key('homeRoute'); |
34 | Key routeTwoKey = const Key('routeTwo'); |
35 | Key routeThreeKey = const Key('routeThree'); |
36 | |
37 | bool transitionFromUserGestures = false; |
38 | |
39 | final 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 | |
156 | class 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 | |
179 | class 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 | |
194 | class _SimpleStatefulWidget extends StatefulWidget { |
195 | const _SimpleStatefulWidget({super.key}); |
196 | @override |
197 | _SimpleState createState() => _SimpleState(); |
198 | } |
199 | |
200 | class _SimpleState extends State<_SimpleStatefulWidget> { |
201 | int state = 0; |
202 | |
203 | @override |
204 | Widget build(BuildContext context) => Text(state.toString()); |
205 | } |
206 | |
207 | class MyStatefulWidget extends StatefulWidget { |
208 | const MyStatefulWidget({super.key, this.value = '123'}); |
209 | final String value; |
210 | @override |
211 | MyStatefulWidgetState createState() => MyStatefulWidgetState(); |
212 | } |
213 | |
214 | class MyStatefulWidgetState extends State<MyStatefulWidget> { |
215 | @override |
216 | Widget build(BuildContext context) => Text(widget.value); |
217 | } |
218 | |
219 | Future<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 | |
3444 | class 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 |
Definitions
- createTestImage
- firstKey
- secondKey
- thirdKey
- simpleKey
- homeRouteKey
- routeTwoKey
- routeThreeKey
- transitionFromUserGestures
- routes
- ThreeRoute
- ThreeRoute
- MutatingRoute
- MutatingRoute
- markNeedsBuild
- _SimpleStatefulWidget
- _SimpleStatefulWidget
- createState
- _SimpleState
- build
- MyStatefulWidget
- MyStatefulWidget
- createState
- MyStatefulWidgetState
- build
- main
- isVisible
- createRectTween
- createRectTween
- shuttleBuilder
- findRenderAnimatedOpacity
- TestDependencies
- TestDependencies
Learn more about Flutter for embedded and desktop on industrialflutter.com