// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' as ui; import 'dart:ui' show WindowPadding; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import '../painting/image_test_utils.dart' show TestImageProvider; Future<ui.Image> createTestImage() { final ui.Paint paint = ui.Paint() ..style = ui.PaintingStyle.stroke ..strokeWidth = 1.0; final ui.PictureRecorder recorder = ui.PictureRecorder(); final ui.Canvas pictureCanvas = ui.Canvas(recorder); pictureCanvas.drawCircle(Offset.zero, 20.0, paint); final ui.Picture picture = recorder.endRecording(); return picture.toImage(300, 300); } Key firstKey = const Key('first'); Key secondKey = const Key('second'); Key thirdKey = const Key('third'); Key simpleKey = const Key('simple'); Key homeRouteKey = const Key('homeRoute'); Key routeTwoKey = const Key('routeTwo'); Key routeThreeKey = const Key('routeThree'); bool transitionFromUserGestures = false; final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => Material( child: ListView( key: homeRouteKey, children: <Widget>[ const SizedBox(height: 100.0, width: 100.0), Card(child: Hero( tag: 'a', transitionOnUserGestures: transitionFromUserGestures, child: SizedBox(height: 100.0, width: 100.0, key: firstKey), )), const SizedBox(height: 100.0, width: 100.0), TextButton( child: const Text('two'), onPressed: () { Navigator.pushNamed(context, '/two'); }, ), TextButton( child: const Text('twoInset'), onPressed: () { Navigator.pushNamed(context, '/twoInset'); }, ), TextButton( child: const Text('simple'), onPressed: () { Navigator.pushNamed(context, '/simple'); }, ), ], ), ), '/two': (BuildContext context) => Material( child: ListView( key: routeTwoKey, children: <Widget>[ TextButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); }, ), const SizedBox(height: 150.0, width: 150.0), Card(child: Hero( tag: 'a', transitionOnUserGestures: transitionFromUserGestures, child: SizedBox(height: 150.0, width: 150.0, key: secondKey), )), const SizedBox(height: 150.0, width: 150.0), TextButton( child: const Text('three'), onPressed: () { Navigator.push(context, ThreeRoute()); }, ), ], ), ), // This route is the same as /two except that Hero 'a' is shifted to the right by // 50 pixels. When the hero's in-flight bounds between / and /twoInset are animated // using MaterialRectArcTween (the default) they'll follow a different path // then when the flight starts at /twoInset and returns to /. '/twoInset': (BuildContext context) => Material( child: ListView( key: routeTwoKey, children: <Widget>[ TextButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); }, ), const SizedBox(height: 150.0, width: 150.0), Card( child: Padding( padding: const EdgeInsets.only(left: 50.0), child: Hero( tag: 'a', transitionOnUserGestures: transitionFromUserGestures, child: SizedBox(height: 150.0, width: 150.0, key: secondKey), ), ), ), const SizedBox(height: 150.0, width: 150.0), TextButton( child: const Text('three'), onPressed: () { Navigator.push(context, ThreeRoute()); }, ), ], ), ), // This route is the same as /two except that Hero 'a' is shifted to the right by // 50 pixels. When the hero's in-flight bounds between / and /twoInset are animated // using MaterialRectArcTween (the default) they'll follow a different path // then when the flight starts at /twoInset and returns to /. '/simple': (BuildContext context) => CupertinoPageScaffold( child: Center( child: Hero( tag: 'a', transitionOnUserGestures: transitionFromUserGestures, child: SizedBox(height: 150.0, width: 150.0, key: simpleKey), ), ), ), }; class ThreeRoute extends MaterialPageRoute<void> { ThreeRoute() : super(builder: (BuildContext context) { return Material( key: routeThreeKey, child: ListView( children: <Widget>[ const SizedBox(height: 200.0, width: 200.0), Card(child: Hero(tag: 'a', child: SizedBox(height: 200.0, width: 200.0, key: thirdKey))), const SizedBox(height: 200.0, width: 200.0), ], ), ); }); } class MutatingRoute extends MaterialPageRoute<void> { MutatingRoute() : super(builder: (BuildContext context) { return Hero(tag: 'a', key: UniqueKey(), child: const Text('MutatingRoute')); }); void markNeedsBuild() { setState(() { // Trigger a rebuild }); } } class _SimpleStatefulWidget extends StatefulWidget { const _SimpleStatefulWidget({ super.key }); @override _SimpleState createState() => _SimpleState(); } class _SimpleState extends State<_SimpleStatefulWidget> { int state = 0; @override Widget build(BuildContext context) => Text(state.toString()); } class MyStatefulWidget extends StatefulWidget { const MyStatefulWidget({ super.key, this.value = '123' }); final String value; @override MyStatefulWidgetState createState() => MyStatefulWidgetState(); } class MyStatefulWidgetState extends State<MyStatefulWidget> { @override Widget build(BuildContext context) => Text(widget.value); } class FakeWindowPadding implements WindowPadding { const FakeWindowPadding({ this.left = 0.0, this.top = 0.0, this.right = 0.0, this.bottom = 0.0, }); @override final double left; @override final double top; @override final double right; @override final double bottom; } Future<void> main() async { final ui.Image testImage = await createTestImage(); assert(testImage != null); setUp(() { transitionFromUserGestures = false; }); testWidgets('Heroes animate', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(routes: routes)); // the initial setup. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('two')); await tester.pump(); // begin navigation // at this stage, the second route is offstage, so that we can form the // hero party. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey, skipOffstage: false), isOffstage); expect(find.byKey(secondKey, skipOffstage: false), isInCard); await tester.pump(); // at this stage, the heroes have just gone on their journey, we are // seeing them at t=16ms. The original page no longer contains the hero. expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), isNotInCard); expect(find.byKey(secondKey), isOnstage); await tester.pump(); // t=32ms for the journey. Surely they are still at it. expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), isNotInCard); expect(find.byKey(secondKey), isOnstage); await tester.pump(const Duration(seconds: 1)); // t=1.032s for the journey. The journey has ended (it ends this frame, in // fact). The hero should now be in the new page, onstage. The original // widget will be back as well now (though not visible). expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); await tester.pump(); // Should not change anything. expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); // Now move on to view 3 await tester.tap(find.text('three')); await tester.pump(); // begin navigation // at this stage, the second route is offstage, so that we can form the // hero party. expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); expect(find.byKey(thirdKey, skipOffstage: false), isOffstage); expect(find.byKey(thirdKey, skipOffstage: false), isInCard); await tester.pump(); // at this stage, the heroes have just gone on their journey, we are // seeing them at t=16ms. The original page no longer contains the hero. expect(find.byKey(secondKey), findsNothing); expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isNotInCard); await tester.pump(); // t=32ms for the journey. Surely they are still at it. expect(find.byKey(secondKey), findsNothing); expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isNotInCard); await tester.pump(const Duration(seconds: 1)); // t=1.032s for the journey. The journey has ended (it ends this frame, in // fact). The hero should now be in the new page, onstage. expect(find.byKey(secondKey), findsNothing); expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isInCard); await tester.pump(); // Should not change anything. expect(find.byKey(secondKey), findsNothing); expect(find.byKey(thirdKey), isOnstage); expect(find.byKey(thirdKey), isInCard); }); testWidgets('Heroes still animate after hero controller is swapped.', (WidgetTester tester) async { final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>(); final UniqueKey heroKey = UniqueKey(); await tester.pumpWidget( HeroControllerScope( controller: HeroController(), child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key, initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return Hero( tag: 'hero', child: Container(), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return Container(key: heroKey); }, ); }, settings: s, ); }, ), ), ), ); key.currentState!.push(MaterialPageRoute<void>( builder: (BuildContext c) { return Hero( tag: 'hero', child: Container(), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return Container(key: heroKey); }, ); }, )); expect(find.byKey(heroKey), findsNothing); // Begins the navigation await tester.pump(); await tester.pump(const Duration(milliseconds: 30)); expect(find.byKey(heroKey), isOnstage); // Pumps a new hero controller. await tester.pumpWidget( HeroControllerScope( controller: HeroController(), child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key, initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return Hero( tag: 'hero', child: Container(), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return Container(key: heroKey); }, ); }, settings: s, ); }, ), ), ), ); // The original animation still flies. expect(find.byKey(heroKey), isOnstage); // Waits for the animation finishes. await tester.pumpAndSettle(); expect(find.byKey(heroKey), findsNothing); }); testWidgets('Heroes animate should hide original hero', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(routes: routes)); // Checks initial state. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('two')); await tester.pumpAndSettle(); // Waits for transition finishes. expect(find.byKey(firstKey), findsNothing); final Offstage first = tester.widget( find.ancestor( of: find.byKey(firstKey, skipOffstage: false), matching: find.byType(Offstage, skipOffstage: false), ).first, ); // Original hero should stay hidden. expect(first.offstage, isTrue); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); }); testWidgets('Destination hero is rebuilt midflight', (WidgetTester tester) async { final MutatingRoute route = MutatingRoute(); await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ const Hero(tag: 'a', child: Text('foo')), Builder(builder: (BuildContext context) { return TextButton(child: const Text('two'), onPressed: () => Navigator.push(context, route)); }), ], ), ), )); await tester.tap(find.text('two')); await tester.pump(const Duration(milliseconds: 10)); route.markNeedsBuild(); await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(seconds: 1)); }); testWidgets('Heroes animation is fastOutSlowIn', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(routes: routes)); await tester.tap(find.text('two')); await tester.pump(); // begin navigation // Expect the height of the secondKey Hero to vary from 100 to 150 // over duration and according to curve. const Duration duration = Duration(milliseconds: 300); const Curve curve = Curves.fastOutSlowIn; final double initialHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height; final double finalHeight = tester.getSize(find.byKey(secondKey, skipOffstage: false)).height; final double deltaHeight = finalHeight - initialHeight; const double epsilon = 0.001; await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, moreOrLessEquals(curve.transform(0.25) * deltaHeight + initialHeight, epsilon: epsilon), ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, moreOrLessEquals(curve.transform(0.50) * deltaHeight + initialHeight, epsilon: epsilon), ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, moreOrLessEquals(curve.transform(0.75) * deltaHeight + initialHeight, epsilon: epsilon), ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, moreOrLessEquals(curve.transform(1.0) * deltaHeight + initialHeight, epsilon: epsilon), ); }); testWidgets('Heroes are not interactive', (WidgetTester tester) async { final List<String> log = <String>[]; await tester.pumpWidget(MaterialApp( home: Center( child: Hero( tag: 'foo', child: GestureDetector( onTap: () { log.add('foo'); }, child: const SizedBox( width: 100.0, height: 100.0, child: Text('foo'), ), ), ), ), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return Align( alignment: Alignment.topLeft, child: Hero( tag: 'foo', child: GestureDetector( onTap: () { log.add('bar'); }, child: const SizedBox( width: 100.0, height: 150.0, child: Text('bar'), ), ), ), ); }, }, )); expect(log, isEmpty); await tester.tap(find.text('foo')); expect(log, equals(<String>['foo'])); log.clear(); final NavigatorState navigator = tester.state(find.byType(Navigator)); navigator.pushNamed('/next'); expect(log, isEmpty); await tester.tap(find.text('foo', skipOffstage: false), warnIfMissed: false); expect(log, isEmpty); await tester.pump(const Duration(milliseconds: 10)); await tester.tap(find.text('foo', skipOffstage: false), warnIfMissed: false); expect(log, isEmpty); await tester.tap(find.text('bar', skipOffstage: false), warnIfMissed: false); expect(log, isEmpty); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); await tester.tap(find.text('bar', skipOffstage: false), warnIfMissed: false); expect(log, isEmpty); await tester.pump(const Duration(seconds: 1)); expect(find.text('foo'), findsNothing); await tester.tap(find.text('bar')); expect(log, equals(<String>['bar'])); }); testWidgets('Popping on first frame does not cause hero observer to crash', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => Hero(tag: 'test', child: Container()), ); }, )); await tester.pump(); final Finder heroes = find.byType(Hero); expect(heroes, findsOneWidget); Navigator.pushNamed(heroes.evaluate().first, 'test'); await tester.pump(); // adds the new page to the tree... Navigator.pop(heroes.evaluate().first); await tester.pump(); // ...and removes it straight away (since it's already at 0.0) }); testWidgets('Overlapping starting and ending a hero transition works ok', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => Hero(tag: 'test', child: Container()), ); }, )); await tester.pump(); final Finder heroes = find.byType(Hero); expect(heroes, findsOneWidget); Navigator.pushNamed(heroes.evaluate().first, 'test'); await tester.pump(); await tester.pump(const Duration(hours: 1)); Navigator.pushNamed(heroes.evaluate().first, 'test'); await tester.pump(); await tester.pump(const Duration(hours: 1)); Navigator.pop(heroes.evaluate().first); await tester.pump(); Navigator.pop(heroes.evaluate().first); await tester.pump(const Duration(hours: 1)); // so the first transition is finished, but the second hasn't started await tester.pump(); }); testWidgets('One route, two heroes, same tag, throws', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ const Hero(tag: 'a', child: Text('a')), const Hero(tag: 'a', child: Text('a too')), Builder( builder: (BuildContext context) { return TextButton( child: const Text('push'), onPressed: () { Navigator.push(context, PageRouteBuilder<void>( pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return const Text('fail'); }, )); }, ); }, ), ], ), ), )); await tester.tap(find.text('push')); await tester.pump(); final dynamic exception = tester.takeException(); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect(error.diagnostics.length, 3); final DiagnosticsNode last = error.diagnostics.last; expect(last, isA<DiagnosticsProperty<StatefulElement>>()); expect( last.toStringDeep(), equalsIgnoringHashCodes( '# Here is the subtree for one of the offending heroes: Hero\n', ), ); expect(last.style, DiagnosticsTreeStyle.dense); expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' There are multiple heroes that share the same tag within a\n' ' subtree.\n' ' Within each subtree for which heroes are to be animated (i.e. a\n' ' PageRoute subtree), each Hero must have a unique non-null tag.\n' ' In this case, multiple heroes had the following tag: a\n' ' ├# Here is the subtree for one of the offending heroes: Hero\n', ), ); }); testWidgets('Hero push transition interrupted by a pop', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( routes: routes, )); // Initially the firstKey Card on the '/' route is visible expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); // Pushes MaterialPageRoute '/two'. await tester.tap(find.text('two')); // Start the flight of Hero 'a' from route '/' to route '/two'. Route '/two' // is now offstage. await tester.pump(); final double initialHeight = tester.getSize(find.byKey(firstKey)).height; final double finalHeight = tester.getSize(find.byKey(secondKey, skipOffstage: false)).height; expect(finalHeight, greaterThan(initialHeight)); // simplify the checks below // Build the first hero animation frame in the navigator's overlay. await tester.pump(); // At this point the hero widgets have been replaced by placeholders // and the destination hero has been moved to the overlay. expect(find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)), findsNothing); expect(find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)), findsNothing); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); // The duration of a MaterialPageRoute's transition is 300ms. // At 150ms Hero 'a' is mid-flight. await tester.pump(const Duration(milliseconds: 150)); final double height150ms = tester.getSize(find.byKey(secondKey)).height; expect(height150ms, greaterThan(initialHeight)); expect(height150ms, lessThan(finalHeight)); // Pop route '/two' before the push transition to '/two' has finished. await tester.tap(find.text('pop')); // Restart the flight of Hero 'a'. Now it's flying from route '/two' to // route '/'. await tester.pump(); // After flying in the opposite direction for 50ms Hero 'a' will // be smaller than it was, but bigger than its initial size. await tester.pump(const Duration(milliseconds: 50)); final double height100ms = tester.getSize(find.byKey(secondKey)).height; expect(height100ms, lessThan(height150ms)); expect(finalHeight, greaterThan(height100ms)); // Hero a's return flight at 149ms. The outgoing (push) flight took // 150ms so we should be just about back to where Hero 'a' started. const double epsilon = 0.001; await tester.pump(const Duration(milliseconds: 99)); moreOrLessEquals(tester.getSize(find.byKey(secondKey)).height - initialHeight, epsilon: epsilon); // The flight is finished. We're back to where we started. await tester.pump(const Duration(milliseconds: 300)); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); }); testWidgets('Hero pop transition interrupted by a push', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( routes: routes, theme: ThemeData(pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, )), ), ); // Pushes MaterialPageRoute '/two'. await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); // Now the secondKey Card on the '/2' route is visible expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); expect(find.byKey(firstKey), findsNothing); // Pop MaterialPageRoute '/two'. await tester.tap(find.text('pop')); // Start the flight of Hero 'a' from route '/two' to route '/'. Route '/two' // is now offstage. await tester.pump(); final double initialHeight = tester.getSize(find.byKey(secondKey)).height; final double finalHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height; expect(finalHeight, lessThan(initialHeight)); // simplify the checks below // Build the first hero animation frame in the navigator's overlay. await tester.pump(); // At this point the hero widgets have been replaced by placeholders // and the destination hero has been moved to the overlay. expect(find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)), findsNothing); expect(find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)), findsNothing); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(secondKey), findsNothing); // The duration of a MaterialPageRoute's transition is 300ms. // At 150ms Hero 'a' is mid-flight. await tester.pump(const Duration(milliseconds: 150)); final double height150ms = tester.getSize(find.byKey(firstKey)).height; expect(height150ms, lessThan(initialHeight)); expect(height150ms, greaterThan(finalHeight)); // Push route '/two' before the pop transition from '/two' has finished. await tester.tap(find.text('two')); // Restart the flight of Hero 'a'. Now it's flying from route '/' to // route '/two'. await tester.pump(); // After flying in the opposite direction for 50ms Hero 'a' will // be smaller than it was, but bigger than its initial size. await tester.pump(const Duration(milliseconds: 50)); final double height200ms = tester.getSize(find.byKey(firstKey)).height; expect(height200ms, greaterThan(height150ms)); expect(finalHeight, lessThan(height200ms)); // Hero a's return flight at 149ms. The outgoing (push) flight took // 150ms so we should be just about back to where Hero 'a' started. const double epsilon = 0.001; await tester.pump(const Duration(milliseconds: 99)); moreOrLessEquals(tester.getSize(find.byKey(firstKey)).height - initialHeight, epsilon: epsilon); // The flight is finished. We're back to where we started. await tester.pump(const Duration(milliseconds: 300)); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); expect(find.byKey(firstKey), findsNothing); }); testWidgets('Destination hero disappears mid-flight', (WidgetTester tester) async { const Key homeHeroKey = Key('home hero'); const Key routeHeroKey = Key('route hero'); bool routeIncludesHero = true; late StateSetter heroCardSetState; // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( children: <Widget>[ StatefulBuilder( builder: (BuildContext context, StateSetter setState) { heroCardSetState = setState; return Card( child: routeIncludesHero ? const Hero(tag: 'H', child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0)) : const SizedBox(height: 200.0, width: 200.0), ); }, ), TextButton( child: const Text('POP'), onPressed: () { Navigator.pop(context); }, ), ], ), ); }, ); // Show a 100x100 Hero tagged 'H' with key homeHeroKey await tester.pumpWidget( MaterialApp( home: Scaffold( body: Builder( builder: (BuildContext context) { // Navigator.push() needs context return ListView( children: <Widget> [ const Card( child: Hero(tag: 'H', child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0)), ), TextButton( child: const Text('PUSH'), onPressed: () { Navigator.push(context, route); }, ), ], ); }, ), ), ), ); // Pushes route await tester.tap(find.text('PUSH')); await tester.pump(); await tester.pump(); final double initialHeight = tester.getSize(find.byKey(routeHeroKey)).height; await tester.pump(const Duration(milliseconds: 10)); double midflightHeight = tester.getSize(find.byKey(routeHeroKey)).height; expect(midflightHeight, greaterThan(initialHeight)); expect(midflightHeight, lessThan(200.0)); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); double finalHeight = tester.getSize(find.byKey(routeHeroKey)).height; expect(finalHeight, 200.0); // Complete the flight await tester.pump(const Duration(milliseconds: 100)); // Rebuild route with its Hero heroCardSetState(() { routeIncludesHero = true; }); await tester.pump(); // Pops route await tester.tap(find.text('POP')); await tester.pump(); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); midflightHeight = tester.getSize(find.byKey(homeHeroKey)).height; expect(midflightHeight, lessThan(finalHeight)); expect(midflightHeight, greaterThan(100.0)); // Remove the destination hero midflight heroCardSetState(() { routeIncludesHero = false; }); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); finalHeight = tester.getSize(find.byKey(homeHeroKey)).height; expect(finalHeight, 100.0); }); testWidgets('Destination hero scrolls mid-flight', (WidgetTester tester) async { const Key homeHeroKey = Key('home hero'); const Key routeHeroKey = Key('route hero'); const Key routeContainerKey = Key('route hero container'); // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 Container( key: routeContainerKey, child: const Hero(tag: 'H', child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0)), ), TextButton( child: const Text('POP'), onPressed: () { Navigator.pop(context); }, ), const SizedBox(height: 600.0), ], ), ); }, ); // Show a 100x100 Hero tagged 'H' with key homeHeroKey await tester.pumpWidget( MaterialApp( theme: ThemeData( pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), ), home: Scaffold( body: Builder( builder: (BuildContext context) { // Navigator.push() needs context return ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 const Hero(tag: 'H', child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0)), TextButton( child: const Text('PUSH'), onPressed: () { Navigator.push(context, route); }, ), const SizedBox(height: 600.0), ], ); }, ), ), ), ); // Pushes route await tester.tap(find.text('PUSH')); await tester.pump(); await tester.pump(); final double initialY = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(initialY, 200.0); await tester.pump(const Duration(milliseconds: 100)); final double yAt100ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(yAt100ms, lessThan(200.0)); expect(yAt100ms, greaterThan(100.0)); // Scroll the target upwards by 25 pixels. The Hero flight's Y coordinate // will be redirected from 100 to 75. await tester.drag(find.byKey(routeContainerKey), const Offset(0.0, -25.0), warnIfMissed: false); // the container itself wouldn't be hit await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); final double yAt110ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(yAt110ms, lessThan(yAt100ms)); expect(yAt110ms, greaterThan(75.0)); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); final double finalHeroY = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(finalHeroY, 75.0); // 100 less 25 for the scroll }); testWidgets('Destination hero scrolls out of view mid-flight', (WidgetTester tester) async { const Key homeHeroKey = Key('home hero'); const Key routeHeroKey = Key('route hero'); const Key routeContainerKey = Key('route hero container'); // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( cacheExtent: 0.0, children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 Container( key: routeContainerKey, child: const Hero(tag: 'H', child: SizedBox(key: routeHeroKey, height: 200.0, width: 200.0)), ), const SizedBox(height: 800.0), ], ), ); }, ); // Show a 100x100 Hero tagged 'H' with key homeHeroKey await tester.pumpWidget( MaterialApp( home: Scaffold( body: Builder( builder: (BuildContext context) { // Navigator.push() needs context return ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 const Hero(tag: 'H', child: SizedBox(key: homeHeroKey, height: 100.0, width: 100.0)), TextButton( child: const Text('PUSH'), onPressed: () { Navigator.push(context, route); }, ), ], ); }, ), ), ), ); // Pushes route await tester.tap(find.text('PUSH')); await tester.pump(); await tester.pump(); final double initialY = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(initialY, 200.0); await tester.pump(const Duration(milliseconds: 100)); final double yAt100ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(yAt100ms, lessThan(200.0)); expect(yAt100ms, greaterThan(100.0)); await tester.drag(find.byKey(routeContainerKey), const Offset(0.0, -400.0), warnIfMissed: false); // the container itself wouldn't be hit await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.byKey(routeContainerKey), findsNothing); // Scrolled off the top // Flight continues (the hero will fade out) even though the destination // no longer exists. final double yAt110ms = tester.getTopLeft(find.byKey(routeHeroKey)).dy; expect(yAt110ms, lessThan(yAt100ms)); expect(yAt110ms, greaterThan(100.0)); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); expect(find.byKey(routeHeroKey), findsNothing); }); testWidgets('Aborted flight', (WidgetTester tester) async { // See https://github.com/flutter/flutter/issues/5798 const Key heroABKey = Key('AB hero'); const Key heroBCKey = Key('BC hero'); // Show a 150x150 Hero tagged 'BC' final MaterialPageRoute<void> routeC = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( children: const <Widget>[ // This container will appear at Y=0 Hero( tag: 'BC', child: SizedBox( key: heroBCKey, height: 150.0, child: Text('Hero'), ), ), SizedBox(height: 800.0), ], ), ); }, ); // Show a height=200 Hero tagged 'AB' and a height=50 Hero tagged 'BC' final MaterialPageRoute<void> routeB = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 const Hero( tag: 'AB', child: SizedBox( key: heroABKey, height: 200.0, child: Text('Hero'), ), ), TextButton( child: const Text('PUSH C'), onPressed: () { Navigator.push(context, routeC); }, ), const Hero( tag: 'BC', child: SizedBox( height: 150.0, child: Text('Hero'), ), ), const SizedBox(height: 800.0), ], ), ); }, ); // Show a 100x100 Hero tagged 'AB' with key heroABKey await tester.pumpWidget( MaterialApp( home: Scaffold( body: Builder( builder: (BuildContext context) { // Navigator.push() needs context return ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 const Hero( tag: 'AB', child: SizedBox( height: 100.0, width: 100.0, child: Text('Hero'), ), ), TextButton( child: const Text('PUSH B'), onPressed: () { Navigator.push(context, routeB); }, ), ], ); }, ), ), ), ); // Pushes routeB await tester.tap(find.text('PUSH B')); await tester.pump(); await tester.pump(); final double initialY = tester.getTopLeft(find.byKey(heroABKey)).dy; expect(initialY, 200.0); await tester.pump(const Duration(milliseconds: 200)); final double yAt200ms = tester.getTopLeft(find.byKey(heroABKey)).dy; // Hero AB is mid flight. expect(yAt200ms, lessThan(200.0)); expect(yAt200ms, greaterThan(100.0)); // Pushes route C, causes hero AB's flight to abort, hero BC's flight to start await tester.tap(find.text('PUSH C')); await tester.pump(); await tester.pump(); // Hero AB's aborted flight finishes where it was expected although // it's been faded out. await tester.pump(const Duration(milliseconds: 100)); expect(tester.getTopLeft(find.byKey(heroABKey)).dy, 100.0); bool isVisible(Element node) { bool visible = true; node.visitAncestorElements((Element ancestor) { final RenderObject r = ancestor.renderObject!; if (r is RenderAnimatedOpacity && r.opacity.value == 0) { visible = false; return false; } return true; }); return visible; } // Of all heroes only one should be visible now. final Iterable<Element> elements = find.text('Hero').evaluate(); expect(elements.where(isVisible).length, 1); // Hero BC's flight finishes normally. await tester.pump(const Duration(milliseconds: 300)); expect(tester.getTopLeft(find.byKey(heroBCKey)).dy, 0.0); }); testWidgets('Stateful hero child state survives flight', (WidgetTester tester) async { final MaterialPageRoute<void> route = MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: ListView( children: <Widget>[ const Card( child: Hero( tag: 'H', child: SizedBox( height: 200.0, child: MyStatefulWidget(value: '456'), ), ), ), TextButton( child: const Text('POP'), onPressed: () { Navigator.pop(context); }, ), ], ), ); }, ); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Builder( builder: (BuildContext context) { // Navigator.push() needs context return ListView( children: <Widget> [ const Card( child: Hero( tag: 'H', child: SizedBox( height: 100.0, child: MyStatefulWidget(value: '456'), ), ), ), TextButton( child: const Text('PUSH'), onPressed: () { Navigator.push(context, route); }, ), ], ); }, ), ), ), ); expect(find.text('456'), findsOneWidget); // Push route. await tester.tap(find.text('PUSH')); await tester.pump(); await tester.pump(); // Push flight underway. await tester.pump(const Duration(milliseconds: 100)); // Visible in the hero animation. expect(find.text('456'), findsOneWidget); // Push flight finished. await tester.pump(const Duration(milliseconds: 300)); expect(find.text('456'), findsOneWidget); // Pop route. await tester.tap(find.text('POP')); await tester.pump(); await tester.pump(); // Pop flight underway. await tester.pump(const Duration(milliseconds: 100)); expect(find.text('456'), findsOneWidget); // Pop flight finished await tester.pump(const Duration(milliseconds: 300)); expect(find.text('456'), findsOneWidget); }); testWidgets('Hero createRectTween', (WidgetTester tester) async { RectTween createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); } final Map<String, WidgetBuilder> createRectTweenHeroRoutes = <String, WidgetBuilder>{ '/': (BuildContext context) => Material( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Hero( tag: 'a', createRectTween: createRectTween, child: SizedBox(height: 100.0, width: 100.0, key: firstKey), ), TextButton( child: const Text('two'), onPressed: () { Navigator.pushNamed(context, '/two'); }, ), ], ), ), '/two': (BuildContext context) => Material( child: Column( children: <Widget>[ SizedBox( height: 200.0, child: TextButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); }, ), ), Hero( tag: 'a', createRectTween: createRectTween, child: SizedBox(height: 200.0, width: 100.0, key: secondKey), ), ], ), ), }; await tester.pumpWidget(MaterialApp(routes: createRectTweenHeroRoutes)); expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0)); const double epsilon = 0.001; const Duration duration = Duration(milliseconds: 300); const Curve curve = Curves.fastOutSlowIn; final MaterialPointArcTween pushCenterTween = MaterialPointArcTween( begin: const Offset(50.0, 50.0), end: const Offset(400.0, 300.0), ); await tester.tap(find.text('two')); await tester.pump(); // begin navigation // Verify that the center of the secondKey Hero flies along the // pushCenterTween arc for the push /two flight. await tester.pump(); expect(tester.getCenter(find.byKey(secondKey)), const Offset(50.0, 50.0)); await tester.pump(duration * 0.25); Offset actualHeroCenter = tester.getCenter(find.byKey(secondKey)); Offset predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.25)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pump(duration * 0.25); actualHeroCenter = tester.getCenter(find.byKey(secondKey)); predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.5)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pump(duration * 0.25); actualHeroCenter = tester.getCenter(find.byKey(secondKey)); predictedHeroCenter = pushCenterTween.lerp(curve.transform(0.75)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pumpAndSettle(); expect(tester.getCenter(find.byKey(secondKey)), const Offset(400.0, 300.0)); // Verify that the center of the firstKey Hero flies along the // pushCenterTween arc for the pop /two flight. await tester.tap(find.text('pop')); await tester.pump(); // begin navigation final MaterialPointArcTween popCenterTween = MaterialPointArcTween( begin: const Offset(400.0, 300.0), end: const Offset(50.0, 50.0), ); await tester.pump(); expect(tester.getCenter(find.byKey(firstKey)), const Offset(400.0, 300.0)); await tester.pump(duration * 0.25); actualHeroCenter = tester.getCenter(find.byKey(firstKey)); predictedHeroCenter = popCenterTween.lerp(curve.transform(0.25)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pump(duration * 0.25); actualHeroCenter = tester.getCenter(find.byKey(firstKey)); predictedHeroCenter = popCenterTween.lerp(curve.transform(0.5)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pump(duration * 0.25); actualHeroCenter = tester.getCenter(find.byKey(firstKey)); predictedHeroCenter = popCenterTween.lerp(curve.transform(0.75)); expect(actualHeroCenter, within<Offset>(distance: epsilon, from: predictedHeroCenter)); await tester.pumpAndSettle(); expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0)); }); testWidgets('Hero createRectTween for Navigator that is not full screen', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/25272 RectTween createRectTween(Rect? begin, Rect? end) { return RectTween(begin: begin, end: end); } final Map<String, WidgetBuilder> createRectTweenHeroRoutes = <String, WidgetBuilder>{ '/': (BuildContext context) => Material( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Hero( tag: 'a', createRectTween: createRectTween, child: SizedBox(height: 100.0, width: 100.0, key: firstKey), ), TextButton( child: const Text('two'), onPressed: () { Navigator.pushNamed(context, '/two'); }, ), ], ), ), '/two': (BuildContext context) => Material( child: Column( children: <Widget>[ SizedBox( height: 200.0, child: TextButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); }, ), ), Hero( tag: 'a', createRectTween: createRectTween, child: SizedBox(height: 200.0, width: 100.0, key: secondKey), ), ], ), ), }; const double leftPadding = 10.0; // MaterialApp and its Navigator are offset from the left await tester.pumpWidget(Padding( padding: const EdgeInsets.only(left: leftPadding), child: MaterialApp(routes: createRectTweenHeroRoutes), )); expect(tester.getCenter(find.byKey(firstKey)), const Offset(leftPadding + 50.0, 50.0)); const double epsilon = 0.001; const Duration duration = Duration(milliseconds: 300); const Curve curve = Curves.fastOutSlowIn; final RectTween pushRectTween = RectTween( begin: const Rect.fromLTWH(leftPadding, 0.0, 100.0, 100.0), end: const Rect.fromLTWH(350.0 + leftPadding / 2, 200.0, 100.0, 200.0), ); await tester.tap(find.text('two')); await tester.pump(); // begin navigation // Verify that the rect of the secondKey Hero transforms as the // pushRectTween rect for the push /two flight. await tester.pump(); expect(tester.getCenter(find.byKey(secondKey)), const Offset(50.0 + leftPadding, 50.0)); await tester.pump(duration * 0.25); Rect actualHeroRect = tester.getRect(find.byKey(secondKey)); Rect predictedHeroRect = pushRectTween.lerp(curve.transform(0.25))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pump(duration * 0.25); actualHeroRect = tester.getRect(find.byKey(secondKey)); predictedHeroRect = pushRectTween.lerp(curve.transform(0.5))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pump(duration * 0.25); actualHeroRect = tester.getRect(find.byKey(secondKey)); predictedHeroRect = pushRectTween.lerp(curve.transform(0.75))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pumpAndSettle(); expect(tester.getCenter(find.byKey(secondKey)), const Offset(400.0 + leftPadding / 2, 300.0)); // Verify that the rect of the firstKey Hero transforms as the // pushRectTween rect for the pop /two flight. await tester.tap(find.text('pop')); await tester.pump(); // begin navigation final RectTween popRectTween = RectTween( begin: const Rect.fromLTWH(350.0 + leftPadding / 2, 200.0, 100.0, 200.0), end: const Rect.fromLTWH(leftPadding, 0.0, 100.0, 100.0), ); await tester.pump(); expect(tester.getCenter(find.byKey(firstKey)), const Offset(400.0 + leftPadding / 2, 300.0)); await tester.pump(duration * 0.25); actualHeroRect = tester.getRect(find.byKey(firstKey)); predictedHeroRect = popRectTween.lerp(curve.transform(0.25))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pump(duration * 0.25); actualHeroRect = tester.getRect(find.byKey(firstKey)); predictedHeroRect = popRectTween.lerp(curve.transform(0.5))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pump(duration * 0.25); actualHeroRect = tester.getRect(find.byKey(firstKey)); predictedHeroRect = popRectTween.lerp(curve.transform(0.75))!; expect(actualHeroRect, within<Rect>(distance: epsilon, from: predictedHeroRect)); await tester.pumpAndSettle(); expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0 + leftPadding, 50.0)); }); testWidgets('Pop interrupts push, reverses flight', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(routes: routes)); await tester.tap(find.text('twoInset')); await tester.pump(); // begin navigation from / to /twoInset. const double epsilon = 0.001; const Duration duration = Duration(milliseconds: 300); await tester.pump(); final double x0 = tester.getTopLeft(find.byKey(secondKey)).dx; // Flight begins with the secondKey Hero widget lined up with the firstKey widget. expect(x0, 4.0); await tester.pump(duration * 0.1); final double x1 = tester.getTopLeft(find.byKey(secondKey)).dx; await tester.pump(duration * 0.1); final double x2 = tester.getTopLeft(find.byKey(secondKey)).dx; await tester.pump(duration * 0.1); final double x3 = tester.getTopLeft(find.byKey(secondKey)).dx; await tester.pump(duration * 0.1); final double x4 = tester.getTopLeft(find.byKey(secondKey)).dx; // Pop route /twoInset before the push transition from / to /twoInset has finished. await tester.tap(find.text('pop')); // We expect the hero to take the same path as it did flying from / // to /twoInset as it does now, flying from '/twoInset' back to /. The most // important checks below are the first (x4) and last (x0): the hero should // not jump from where it was when the push transition was interrupted by a // pop, and it should end up where the push started. await tester.pump(); expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x4, epsilon: epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x3, epsilon: epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x2, epsilon: epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x1, epsilon: epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, moreOrLessEquals(x0, epsilon: epsilon)); // Below: show that a different pop Hero path is in fact taken after // a completed push transition. // Complete the pop transition and we're back to showing /. await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byKey(firstKey)).dx, 4.0); // Card contents are inset by 4.0. // Push /twoInset and wait for the transition to finish. await tester.tap(find.text('twoInset')); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byKey(secondKey)).dx, 54.0); // Start the pop transition from /twoInset to /. await tester.tap(find.text('pop')); await tester.pump(); // Now the firstKey widget is the flying hero widget and it starts // out lined up with the secondKey widget. await tester.pump(); expect(tester.getTopLeft(find.byKey(firstKey)).dx, 54.0); // x0-x4 are the top left x coordinates for the beginning 40% of // the incoming flight. Advance the outgoing flight to the same // place. await tester.pump(duration * 0.6); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(firstKey)).dx, isNot(moreOrLessEquals(x4, epsilon: epsilon))); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(firstKey)).dx, isNot(moreOrLessEquals(x3, epsilon: epsilon))); // At this point the flight path arcs do start to get pretty close so // there's no point in comparing them. await tester.pump(duration * 0.1); // After the remaining 40% of the incoming flight is complete, we // expect to end up where the outgoing flight started. await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(firstKey)).dx, x0); }); testWidgets('Can override flight shuttle in to hero', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ const Hero(tag: 'a', child: Text('foo')), Builder(builder: (BuildContext context) { return TextButton( child: const Text('two'), onPressed: () => Navigator.push<void>(context, MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: Hero( tag: 'a', child: const Text('bar'), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return const Text('baz'); }, ), ); }, )), ); }), ], ), ), )); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); expect(find.text('bar'), findsNothing); expect(find.text('baz'), findsOneWidget); }); testWidgets('Can override flight shuttle in from hero', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ Hero( tag: 'a', child: const Text('foo'), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return const Text('baz'); }, ), Builder(builder: (BuildContext context) { return TextButton( child: const Text('two'), onPressed: () => Navigator.push<void>(context, MaterialPageRoute<void>( builder: (BuildContext context) { return const Material( child: Hero(tag: 'a', child: Text('bar')), ); }, )), ); }), ], ), ), )); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); expect(find.text('bar'), findsNothing); expect(find.text('baz'), findsOneWidget); }); // Regression test for https://github.com/flutter/flutter/issues/77720. testWidgets("toHero's shuttle builder over fromHero's shuttle builder", (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ Hero( tag: 'a', child: const Text('foo'), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return const Text('fromHero text'); }, ), Builder(builder: (BuildContext context) { return TextButton( child: const Text('two'), onPressed: () => Navigator.push<void>(context, MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: Hero( tag: 'a', child: const Text('bar'), flightShuttleBuilder: ( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return const Text('toHero text'); }, ), ); }, )), ); }), ], ), ), )); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); expect(find.text('bar'), findsNothing); expect(find.text('fromHero text'), findsNothing); expect(find.text('toHero text'), findsOneWidget); }); testWidgets('Can override flight launch pads', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: ListView( children: <Widget>[ Hero( tag: 'a', child: const Text('Batman'), placeholderBuilder: (BuildContext context, Size heroSize, Widget child) { return const Text('Venom'); }, ), Builder(builder: (BuildContext context) { return TextButton( child: const Text('two'), onPressed: () => Navigator.push<void>(context, MaterialPageRoute<void>( builder: (BuildContext context) { return Material( child: Hero( tag: 'a', child: const Text('Wolverine'), placeholderBuilder: (BuildContext context, Size size, Widget child) { return const Text('Joker'); }, ), ); }, )), ); }), ], ), ), )); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('Batman'), findsNothing); // This shows up once but in the Hero because by default, the destination // Hero child is the widget in flight. expect(find.text('Wolverine'), findsOneWidget); expect(find.text('Venom'), findsOneWidget); expect(find.text('Joker'), findsOneWidget); }); testWidgets('Heroes do not transition on back gestures by default', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( routes: routes, )); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); await gesture.moveBy(const Offset(20.0, 0.0)); await gesture.moveBy(const Offset(180.0, 0.0)); await gesture.up(); await tester.pump(); await tester.pump(); // Both Heroes exist and are seated in their normal parents. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); // To make sure the hero had all chances of starting. await tester.pump(const Duration(milliseconds: 100)); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Heroes can transition on gesture in one frame', (WidgetTester tester) async { transitionFromUserGestures = true; await tester.pumpWidget(MaterialApp( routes: routes, )); await tester.tap(find.text('two')); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); await gesture.moveBy(const Offset(200.0, 0.0)); await tester.pump(); // We're going to page 1 so page 1's Hero is lifted into flight. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isNotInCard); expect(find.byKey(secondKey), findsNothing); // Move further along. await gesture.moveBy(const Offset(500.0, 0.0)); await tester.pump(); // Same results. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isNotInCard); expect(find.byKey(secondKey), findsNothing); await gesture.up(); // Finish transition. await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); // Hero A is back in the card. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Heroes animate should hide destination hero and display original hero in case of dismissed', (WidgetTester tester) async { transitionFromUserGestures = true; await tester.pumpWidget(MaterialApp( routes: routes, )); await tester.tap(find.text('two')); await tester.pumpAndSettle(); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); await gesture.moveBy(const Offset(50.0, 0.0)); await tester.pump(); // It will only register the drag if we move a second time. await gesture.moveBy(const Offset(50.0, 0.0)); await tester.pump(); // We're going to page 1 so page 1's Hero is lifted into flight. expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isNotInCard); expect(find.byKey(secondKey), findsNothing); // Dismisses hero transition. await gesture.up(); await tester.pump(); await tester.pumpAndSettle(); // We goes back to second page. expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Handles transitions when a non-default initial route is set', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( routes: routes, initialRoute: '/two', )); expect(tester.takeException(), isNull); expect(find.text('two'), findsNothing); expect(find.text('three'), findsOneWidget); }); testWidgets('Can push/pop on outer Navigator if nested Navigator contains Heroes', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/28042. const String heroTag = 'You are my hero!'; final GlobalKey<NavigatorState> rootNavigator = GlobalKey(); final GlobalKey<NavigatorState> nestedNavigator = GlobalKey(); final Key nestedRouteHeroBottom = UniqueKey(); final Key nestedRouteHeroTop = UniqueKey(); await tester.pumpWidget( MaterialApp( navigatorKey: rootNavigator, home: Navigator( key: nestedNavigator, onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( builder: (BuildContext context) { return Hero( tag: heroTag, child: Placeholder( key: nestedRouteHeroBottom, ), ); }, ); }, ), ), ); nestedNavigator.currentState!.push(MaterialPageRoute<void>( builder: (BuildContext context) { return Hero( tag: heroTag, child: Placeholder( key: nestedRouteHeroTop, ), ); }, )); await tester.pumpAndSettle(); // Both heroes are in the tree, one is offstage expect(find.byKey(nestedRouteHeroTop), findsOneWidget); expect(find.byKey(nestedRouteHeroBottom), findsNothing); expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget); rootNavigator.currentState!.push(MaterialPageRoute<void>( builder: (BuildContext context) { return const Text('Foo'); }, )); await tester.pumpAndSettle(); expect(find.text('Foo'), findsOneWidget); // Both heroes are still in the tree, both are offstage. expect(find.byKey(nestedRouteHeroBottom), findsNothing); expect(find.byKey(nestedRouteHeroTop), findsNothing); expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget); expect(find.byKey(nestedRouteHeroTop, skipOffstage: false), findsOneWidget); // Doesn't crash. expect(tester.takeException(), isNull); rootNavigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('Foo'), findsNothing); // Both heroes are in the tree, one is offstage expect(find.byKey(nestedRouteHeroTop), findsOneWidget); expect(find.byKey(nestedRouteHeroBottom), findsNothing); expect(find.byKey(nestedRouteHeroBottom, skipOffstage: false), findsOneWidget); }); testWidgets('Can hero from route in root Navigator to route in nested Navigator', (WidgetTester tester) async { const String heroTag = 'foo'; final GlobalKey<NavigatorState> rootNavigator = GlobalKey(); final Key smallContainer = UniqueKey(); final Key largeContainer = UniqueKey(); await tester.pumpWidget( MaterialApp( navigatorKey: rootNavigator, home: Center( child: Card( child: Hero( tag: heroTag, child: Container( key: largeContainer, color: Colors.red, height: 200.0, width: 200.0, ), ), ), ), ), ); // The initial setup. expect(find.byKey(largeContainer), isOnstage); expect(find.byKey(largeContainer), isInCard); expect(find.byKey(smallContainer, skipOffstage: false), findsNothing); rootNavigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) { return Center( child: Card( child: Hero( tag: heroTag, child: Container( key: smallContainer, color: Colors.red, height: 100.0, width: 100.0, ), ), ), ); }, ), ); await tester.pump(); // The second route exists offstage. expect(find.byKey(largeContainer), isOnstage); expect(find.byKey(largeContainer), isInCard); expect(find.byKey(smallContainer, skipOffstage: false), isOffstage); expect(find.byKey(smallContainer, skipOffstage: false), isInCard); await tester.pump(); // The hero started flying. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isNotInCard); await tester.pump(const Duration(milliseconds: 100)); // The hero is in-flight. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isNotInCard); final Size size = tester.getSize(find.byKey(smallContainer)); expect(size.height, greaterThan(100)); expect(size.width, greaterThan(100)); expect(size.height, lessThan(200)); expect(size.width, lessThan(200)); await tester.pumpAndSettle(); // The transition has ended. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isInCard); expect(tester.getSize(find.byKey(smallContainer)), const Size(100,100)); }); testWidgets('Hero within a Hero, throws', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Material( child: Hero( tag: 'a', child: Hero( tag: 'b', child: Text('Child of a Hero'), ), ), ), ), ); expect(tester.takeException(), isAssertionError); }); testWidgets('Can push/pop on outer Navigator if nested Navigators contains same Heroes', (WidgetTester tester) async { const String heroTag = 'foo'; final GlobalKey<NavigatorState> rootNavigator = GlobalKey<NavigatorState>(); final Key rootRouteHero = UniqueKey(); final Key nestedRouteHeroOne = UniqueKey(); final Key nestedRouteHeroTwo = UniqueKey(); final List<Key> keys = <Key>[nestedRouteHeroOne, nestedRouteHeroTwo]; await tester.pumpWidget( CupertinoApp( navigatorKey: rootNavigator, home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem(icon: Icon(Icons.home)), BottomNavigationBarItem(icon: Icon(Icons.favorite)), ], ), tabBuilder: (BuildContext context, int index) { return CupertinoTabView( builder: (BuildContext context) => Hero( tag: heroTag, child: Placeholder( key: keys[index], ), ), ); }, ), ), ); // Show both tabs to init. await tester.tap(find.byIcon(Icons.home)); await tester.pump(); await tester.tap(find.byIcon(Icons.favorite)); await tester.pump(); // Inner heroes are in the tree, one is offstage. expect(find.byKey(nestedRouteHeroTwo), findsOneWidget); expect(find.byKey(nestedRouteHeroOne), findsNothing); expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget); // Root hero is not in the tree. expect(find.byKey(rootRouteHero), findsNothing); rootNavigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => Hero( tag: heroTag, child: Placeholder( key: rootRouteHero, ), ), ), ); await tester.pumpAndSettle(); // Inner heroes are still in the tree, both are offstage. expect(find.byKey(nestedRouteHeroOne), findsNothing); expect(find.byKey(nestedRouteHeroTwo), findsNothing); expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget); expect(find.byKey(nestedRouteHeroTwo, skipOffstage: false), findsOneWidget); // Root hero is in the tree. expect(find.byKey(rootRouteHero), findsOneWidget); // Doesn't crash. expect(tester.takeException(), isNull); rootNavigator.currentState!.pop(); await tester.pumpAndSettle(); // Root hero is not in the tree expect(find.byKey(rootRouteHero), findsNothing); // Both heroes are in the tree, one is offstage expect(find.byKey(nestedRouteHeroTwo), findsOneWidget); expect(find.byKey(nestedRouteHeroOne), findsNothing); expect(find.byKey(nestedRouteHeroOne, skipOffstage: false), findsOneWidget); }); testWidgets('Hero within a Hero subtree, throws', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Material( child: Hero( tag: 'a', child: Hero( tag: 'b', child: Text('Child of a Hero'), ), ), ), ), ); expect(tester.takeException(), isAssertionError); }); testWidgets('Hero within a Hero subtree with Builder, throws', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: Hero( tag: 'a', child: Builder( builder: (BuildContext context) { return const Hero( tag: 'b', child: Text('Child of a Hero'), ); }, ), ), ), ), ); expect(tester.takeException(),isAssertionError); }); testWidgets('Hero within a Hero subtree with LayoutBuilder, throws', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: Hero( tag: 'a', child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return const Hero( tag: 'b', child: Text('Child of a Hero'), ); }, ), ), ), ), ); expect(tester.takeException(), isAssertionError); }); testWidgets('Heroes fly on pushReplacement', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/28041. const String heroTag = 'foo'; final GlobalKey<NavigatorState> navigator = GlobalKey(); final Key smallContainer = UniqueKey(); final Key largeContainer = UniqueKey(); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, home: Center( child: Card( child: Hero( tag: heroTag, child: Container( key: largeContainer, color: Colors.red, height: 200.0, width: 200.0, ), ), ), ), ), ); // The initial setup. expect(find.byKey(largeContainer), isOnstage); expect(find.byKey(largeContainer), isInCard); expect(find.byKey(smallContainer, skipOffstage: false), findsNothing); navigator.currentState!.pushReplacement( MaterialPageRoute<void>( builder: (BuildContext context) { return Center( child: Card( child: Hero( tag: heroTag, child: Container( key: smallContainer, color: Colors.red, height: 100.0, width: 100.0, ), ), ), ); }, ), ); await tester.pump(); // The second route exists offstage. expect(find.byKey(largeContainer), isOnstage); expect(find.byKey(largeContainer), isInCard); expect(find.byKey(smallContainer, skipOffstage: false), isOffstage); expect(find.byKey(smallContainer, skipOffstage: false), isInCard); await tester.pump(); // The hero started flying. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isNotInCard); await tester.pump(const Duration(milliseconds: 100)); // The hero is in-flight. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isNotInCard); final Size size = tester.getSize(find.byKey(smallContainer)); expect(size.height, greaterThan(100)); expect(size.width, greaterThan(100)); expect(size.height, lessThan(200)); expect(size.width, lessThan(200)); await tester.pumpAndSettle(); // The transition has ended. expect(find.byKey(largeContainer), findsNothing); expect(find.byKey(smallContainer), isOnstage); expect(find.byKey(smallContainer), isInCard); expect(tester.getSize(find.byKey(smallContainer)), const Size(100,100)); }); testWidgets('On an iOS back swipe and snap, only a single flight should take place', (WidgetTester tester) async { int shuttlesBuilt = 0; Widget shuttleBuilder( BuildContext flightContext, Animation<double> animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { shuttlesBuilt += 1; return const Text("I'm flying in a jetplane"); } final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); await tester.pumpWidget( CupertinoApp( navigatorKey: navigatorKey, home: Hero( tag: navigatorKey, // Since we're popping, only the destination route's builder is used. flightShuttleBuilder: shuttleBuilder, transitionOnUserGestures: true, child: const Text('1'), ), ), ); final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>( builder: (BuildContext context) { return CupertinoPageScaffold( child: Hero( tag: navigatorKey, transitionOnUserGestures: true, child: const Text('2'), ), ); }, ); navigatorKey.currentState!.push(route2); await tester.pumpAndSettle(); expect(shuttlesBuilt, 1); final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); await gesture.moveBy(const Offset(500.0, 0.0)); await tester.pump(); // Starting the back swipe creates a new hero shuttle. expect(shuttlesBuilt, 2); await gesture.up(); await tester.pump(); // After the lift, no additional shuttles should be created since it's the // same hero flight. expect(shuttlesBuilt, 2); // Did go far enough to snap out of this route. await tester.pump(const Duration(milliseconds: 301)); expect(find.text('2'), findsNothing); // Still one shuttle. expect(shuttlesBuilt, 2); }); testWidgets( "From hero's state should be preserved, " 'heroes work well with child widgets that has global keys', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); final GlobalKey<_SimpleState> key1 = GlobalKey<_SimpleState>(); final GlobalKey key2 = GlobalKey(); await tester.pumpWidget( CupertinoApp( navigatorKey: navigatorKey, home: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Hero( tag: 'hero', transitionOnUserGestures: true, child: _SimpleStatefulWidget(key: key1), ), const SizedBox( width: 10, height: 10, child: Text('1'), ), ], ), ), ); final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>( builder: (BuildContext context) { return CupertinoPageScaffold( child: Hero( tag: 'hero', transitionOnUserGestures: true, // key2 is a `GlobalKey`. The hero animation should not // assert by having the same global keyed widget in more // than one place in the tree. child: _SimpleStatefulWidget(key: key2), ), ); }, ); final _SimpleState state1 = key1.currentState!; state1.state = 1; navigatorKey.currentState!.push(route2); await tester.pump(); expect(state1.mounted, isTrue); await tester.pumpAndSettle(); expect(state1.state, 1); // The element should be mounted and unique. expect(state1.mounted, isTrue); navigatorKey.currentState!.pop(); await tester.pumpAndSettle(); // State is preserved. expect(state1.state, 1); // The element should be mounted and unique. expect(state1.mounted, isTrue); }, ); testWidgets( "Hero works with images that don't have both width and height specified", // Regression test for https://github.com/flutter/flutter/issues/32356 // and https://github.com/flutter/flutter/issues/31503 (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); const Key imageKey1 = Key('image1'); const Key imageKey2 = Key('image2'); final TestImageProvider imageProvider = TestImageProvider(testImage); await tester.pumpWidget( CupertinoApp( navigatorKey: navigatorKey, home: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Hero( tag: 'hero', transitionOnUserGestures: true, child: SizedBox( width: 100, child: Image( image: imageProvider, key: imageKey1, ), ), ), const SizedBox( width: 10, height: 10, child: Text('1'), ), ], ), ), ); final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>( builder: (BuildContext context) { return CupertinoPageScaffold( child: Hero( tag: 'hero', transitionOnUserGestures: true, child: Image( image: imageProvider, key: imageKey2, ), ), ); }, ); // Load image before measuring the `Rect` of the `RenderImage`. imageProvider.complete(); await tester.pump(); final RenderImage renderImage = tester.renderObject( find.descendant(of: find.byKey(imageKey1), matching: find.byType(RawImage)), ); // Before push image1 should be laid out correctly. expect(renderImage.size, const Size(100, 100)); navigatorKey.currentState!.push(route2); await tester.pump(); final TestGesture gesture = await tester.startGesture(const Offset(0.01, 300)); await tester.pump(); // Move (almost) across the screen, to make the animation as close to finish // as possible. await gesture.moveTo(const Offset(800, 200)); await tester.pump(); // image1 should snap to the top left corner of the Row widget. expect( tester.getRect(find.byKey(imageKey1, skipOffstage: false)), rectMoreOrLessEquals(tester.getTopLeft(find.widgetWithText(Row, '1')) & const Size(100, 100), epsilon: 0.01), ); // Text should respect the correct final size of image1. expect( tester.getTopRight(find.byKey(imageKey1, skipOffstage: false)).dx, moreOrLessEquals(tester.getTopLeft(find.text('1')).dx, epsilon: 0.01), ); }, ); // Regression test for https://github.com/flutter/flutter/issues/38183. testWidgets('Remove user gesture driven flights when the gesture is invalid', (WidgetTester tester) async { transitionFromUserGestures = true; await tester.pumpWidget(MaterialApp( routes: routes, )); await tester.tap(find.text('simple')); await tester.pump(); await tester.pumpAndSettle(); expect(find.byKey(simpleKey), findsOneWidget); // Tap once to trigger a flight. await tester.tapAt(const Offset(10, 200)); await tester.pumpAndSettle(); // Wait till the previous gesture is accepted. await tester.pump(const Duration(milliseconds: 500)); // Tap again to trigger another flight, see if it throws. await tester.tapAt(const Offset(10, 200)); await tester.pumpAndSettle(); // The simple route should still be on top. expect(find.byKey(simpleKey), findsOneWidget); expect(tester.takeException(), isNull); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); // Regression test for https://github.com/flutter/flutter/issues/40239. testWidgets( 'In a pop transition, when fromHero is null, the to hero should eventually become visible', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); late StateSetter setState; bool shouldDisplayHero = true; await tester.pumpWidget( CupertinoApp( navigatorKey: navigatorKey, home: Hero( tag: navigatorKey, child: const Placeholder(), ), ), ); final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>( builder: (BuildContext context) { return CupertinoPageScaffold( child: StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return shouldDisplayHero ? Hero(tag: navigatorKey, child: const Text('text')) : const SizedBox(); }, ), ); }, ); navigatorKey.currentState!.push(route2); await tester.pumpAndSettle(); expect(find.text('text'), findsOneWidget); expect(find.byType(Placeholder), findsNothing); setState(() { shouldDisplayHero = false; }); await tester.pumpAndSettle(); expect(find.text('text'), findsNothing); navigatorKey.currentState!.pop(); await tester.pumpAndSettle(); expect(find.byType(Placeholder), findsOneWidget); }, ); testWidgets('popped hero uses fastOutSlowIn curve', (WidgetTester tester) async { final Key container1 = UniqueKey(); final Key container2 = UniqueKey(); final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final Animatable<Size?> tween = SizeTween( begin: const Size(200, 200), end: const Size(100, 100), ).chain(CurveTween(curve: Curves.fastOutSlowIn)); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, home: Scaffold( body: Center( child: Hero( tag: 'test', createRectTween: (Rect? begin, Rect? end) { return RectTween(begin: begin, end: end); }, child: SizedBox( key: container1, height: 100, width: 100, ), ), ), ), ), ); final Size originalSize = tester.getSize(find.byKey(container1)); expect(originalSize, const Size(100, 100)); navigator.currentState!.push(MaterialPageRoute<void>(builder: (BuildContext context) { return Scaffold( body: Center( child: Hero( tag: 'test', createRectTween: (Rect? begin, Rect? end) { return RectTween(begin: begin, end: end); }, child: SizedBox( key: container2, height: 200, width: 200, ), ), ), ); })); await tester.pumpAndSettle(); final Size newSize = tester.getSize(find.byKey(container2)); expect(newSize, const Size(200, 200)); navigator.currentState!.pop(); await tester.pump(); // Jump 25% into the transition (total length = 300ms) await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms Size heroSize = tester.getSize(find.byKey(container1)); expect(heroSize, tween.transform(0.25)); // Jump to 50% into the transition. await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms heroSize = tester.getSize(find.byKey(container1)); expect(heroSize, tween.transform(0.50)); // Jump to 75% into the transition. await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms heroSize = tester.getSize(find.byKey(container1)); expect(heroSize, tween.transform(0.75)); // Jump to 100% into the transition. await tester.pump(const Duration(milliseconds: 75)); // 25% of 300ms heroSize = tester.getSize(find.byKey(container1)); expect(heroSize, tween.transform(1.0)); }); testWidgets('Heroes in enabled HeroMode do transition', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: Column( children: <Widget>[ HeroMode( child: Card( child: Hero( tag: 'a', child: SizedBox( height: 100.0, width: 100.0, key: firstKey, ), ), ), ), Builder( builder: (BuildContext context) { return TextButton( child: const Text('push'), onPressed: () { Navigator.push(context, PageRouteBuilder<void>( pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return Card( child: Hero( tag: 'a', child: SizedBox( height: 150.0, width: 150.0, key: secondKey, ), ), ); }, )); }, ); }, ), ], ), ), )); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('push')); await tester.pump(); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey, skipOffstage: false), isOffstage); expect(find.byKey(secondKey, skipOffstage: false), isInCard); await tester.pump(); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), isNotInCard); expect(find.byKey(secondKey), isOnstage); await tester.pump(const Duration(seconds: 1)); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), isInCard); }); testWidgets('Heroes in disabled HeroMode do not transition', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: Column( children: <Widget>[ HeroMode( enabled: false, child: Card( child: Hero( tag: 'a', child: SizedBox( height: 100.0, width: 100.0, key: firstKey, ), ), ), ), Builder( builder: (BuildContext context) { return TextButton( child: const Text('push'), onPressed: () { Navigator.push(context, PageRouteBuilder<void>( pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return Card( child: Hero( tag: 'a', child: SizedBox( height: 150.0, width: 150.0, key: secondKey, ), ), ); }, )); }, ); }, ), ], ), ), )); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey), findsNothing); await tester.tap(find.text('push')); await tester.pump(); expect(find.byKey(firstKey), isOnstage); expect(find.byKey(firstKey), isInCard); expect(find.byKey(secondKey, skipOffstage: false), isOffstage); expect(find.byKey(secondKey, skipOffstage: false), isInCard); await tester.pump(); // When HeroMode is disabled, heroes will not move. // So the original page contains the hero. expect(find.byKey(firstKey), findsOneWidget); // The hero should be in the new page, onstage, soon. expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), isInCard); expect(find.byKey(secondKey), isOnstage); await tester.pump(const Duration(seconds: 1)); expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), findsOneWidget); expect(find.byKey(secondKey), isInCard); expect(find.byKey(secondKey), isOnstage); }); testWidgets('kept alive Hero does not throw when the transition begins', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: navigatorKey, home: Scaffold( body: ListView( addAutomaticKeepAlives: false, addRepaintBoundaries: false, addSemanticIndexes: false, children: <Widget>[ const KeepAlive( keepAlive: true, child: Hero( tag: 'a', child: Placeholder(), ), ), Container(height: 1000.0), ], ), ), ), ); // Scroll to make the Hero invisible. await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); await tester.pump(); expect(find.byType(TextField), findsNothing); navigatorKey.currentState?.push( MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center( child: Hero( tag: 'a', child: Placeholder(), ), ), ); }, ), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); // The Hero on the new route should be visible . expect(find.byType(Placeholder), findsOneWidget); }); testWidgets('toHero becomes unpaintable after the transition begins', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final ScrollController controller = ScrollController(); RenderAnimatedOpacity? findRenderAnimatedOpacity() { AbstractNode? parent = tester.renderObject(find.byType(Placeholder)); while (parent is RenderObject && parent is! RenderAnimatedOpacity) { parent = parent.parent; } return parent is RenderAnimatedOpacity ? parent : null; } await tester.pumpWidget( MaterialApp( navigatorKey: navigatorKey, home: Scaffold( body: ListView( controller: controller, addAutomaticKeepAlives: false, addRepaintBoundaries: false, addSemanticIndexes: false, children: <Widget>[ const KeepAlive( keepAlive: true, child: Hero( tag: 'a', child: Placeholder(), ), ), Container(height: 1000.0), ], ), ), ), ); navigatorKey.currentState?.push( MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center( child: Hero( tag: 'a', child: Placeholder(), ), ), ); }, ), ); await tester.pump(); await tester.pumpAndSettle(); // Pop the new route, and before the animation finishes we scroll the toHero // to make it unpaintable. navigatorKey.currentState?.pop(); await tester.pump(); controller.jumpTo(1000); // Starts Hero animation and scroll animation almost simultaneously. // Scroll to make the Hero invisible. await tester.pump(); expect(findRenderAnimatedOpacity()?.opacity.value, anyOf(isNull, 1.0)); // In this frame the Hero animation finds out the toHero is not paintable, // and starts fading. await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(findRenderAnimatedOpacity()?.opacity.value, lessThan(1.0)); await tester.pumpAndSettle(); // The Hero on the new route should be invisible. expect(find.byType(Placeholder), findsNothing); }); testWidgets('diverting to a keepalive but unpaintable hero', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( CupertinoApp( navigatorKey: navigatorKey, home: CupertinoPageScaffold( child: ListView( addAutomaticKeepAlives: false, addRepaintBoundaries: false, addSemanticIndexes: false, children: <Widget>[ const KeepAlive( keepAlive: true, child: Hero( tag: 'a', child: Placeholder(), ), ), Container(height: 1000.0), ], ), ), ), ); // Scroll to make the Hero invisible. await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); await tester.pump(); expect(find.byType(Placeholder), findsNothing); expect(find.byType(Placeholder, skipOffstage: false), findsOneWidget); navigatorKey.currentState?.push( MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center( child: Hero( tag: 'a', child: Placeholder(), ), ), ); }, ), ); await tester.pumpAndSettle(); // Yet another route that contains Hero 'a'. navigatorKey.currentState?.push( MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center( child: Hero( tag: 'a', child: Placeholder(), ), ), ); }, ), ); await tester.pumpAndSettle(); // Pop both routes. navigatorKey.currentState?.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); navigatorKey.currentState?.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(find.byType(Placeholder), findsOneWidget); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); testWidgets('smooth transition between different incoming data', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); const Key imageKey1 = Key('image1'); const Key imageKey2 = Key('image2'); final TestImageProvider imageProvider = TestImageProvider(testImage); final TestWidgetsFlutterBinding testBinding = tester.binding; testBinding.window.paddingTestValue = const FakeWindowPadding(top: 50); await tester.pumpWidget( MaterialApp( navigatorKey: navigatorKey, home: Scaffold( appBar: AppBar(title: const Text('test')), body: Hero( tag: 'imageHero', child: GridView.count( crossAxisCount: 3, shrinkWrap: true, children: <Widget>[ Image(image: imageProvider, key: imageKey1), ], ), ), ), ), ); final MaterialPageRoute<void> route2 = MaterialPageRoute<void>( builder: (BuildContext context) { return Scaffold( body: Hero( tag: 'imageHero', child: GridView.count( crossAxisCount: 3, shrinkWrap: true, children: <Widget>[ Image(image: imageProvider, key: imageKey2), ], ), ), ); }, ); // Load images. imageProvider.complete(); await tester.pump(); final double forwardRest = tester.getTopLeft(find.byType(Image)).dy; navigatorKey.currentState!.push(route2); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1)); await tester.pumpAndSettle(); navigatorKey.currentState!.pop(route2); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byType(Image)).dy, moreOrLessEquals(forwardRest, epsilon: 0.1)); testBinding.window.clearAllTestValues(); }, ); }