// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; Key firstKey = const Key('first'); Key secondKey = const Key('second'); Key thirdKey = const Key('third'); Key homeRouteKey = const Key('homeRoute'); Key routeTwoKey = const Key('routeTwo'); Key routeThreeKey = const Key('routeThree'); final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => new Material( child: new ListView( key: homeRouteKey, children: <Widget>[ new Container(height: 100.0, width: 100.0), new Card(child: new Hero(tag: 'a', child: new Container(height: 100.0, width: 100.0, key: firstKey))), new Container(height: 100.0, width: 100.0), new FlatButton( child: const Text('two'), onPressed: () { Navigator.pushNamed(context, '/two'); } ), new FlatButton( child: const Text('twoInset'), onPressed: () { Navigator.pushNamed(context, '/twoInset'); } ), ] ) ), '/two': (BuildContext context) => new Material( child: new ListView( key: routeTwoKey, children: <Widget>[ new FlatButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); } ), new Container(height: 150.0, width: 150.0), new Card(child: new Hero(tag: 'a', child: new Container(height: 150.0, width: 150.0, key: secondKey))), new Container(height: 150.0, width: 150.0), new FlatButton( child: const Text('three'), onPressed: () { Navigator.push(context, new 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) => new Material( child: new ListView( key: routeTwoKey, children: <Widget>[ new FlatButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); } ), new Container(height: 150.0, width: 150.0), new Card( child: new Padding( padding: const EdgeInsets.only(left: 50.0), child: new Hero(tag: 'a', child: new Container(height: 150.0, width: 150.0, key: secondKey)) ), ), new Container(height: 150.0, width: 150.0), new FlatButton( child: const Text('three'), onPressed: () { Navigator.push(context, new ThreeRoute()); }, ), ] ) ), }; class ThreeRoute extends MaterialPageRoute<void> { ThreeRoute() : super(builder: (BuildContext context) { return new Material( key: routeThreeKey, child: new ListView( children: <Widget>[ new Container(height: 200.0, width: 200.0), new Card(child: new Hero(tag: 'a', child: new Container(height: 200.0, width: 200.0, key: thirdKey))), new Container(height: 200.0, width: 200.0), ] ) ); }); } class MutatingRoute extends MaterialPageRoute<void> { MutatingRoute() : super(builder: (BuildContext context) { return new Hero(tag: 'a', child: const Text('MutatingRoute'), key: new UniqueKey()); }); void markNeedsBuild() { setState(() { // Trigger a rebuild }); } } class MyStatefulWidget extends StatefulWidget { const MyStatefulWidget({ Key key, this.value = '123' }) : super(key: key); final String value; @override MyStatefulWidgetState createState() => new MyStatefulWidgetState(); } class MyStatefulWidgetState extends State<MyStatefulWidget> { @override Widget build(BuildContext context) => new Text(widget.value); } void main() { testWidgets('Heroes animate', (WidgetTester tester) async { await tester.pumpWidget(new 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), isOnstage); expect(find.byKey(secondKey), isNotInCard); await tester.pump(); // t=32ms for the journey. Surely they are still at it. expect(find.byKey(firstKey), findsNothing); expect(find.byKey(secondKey), isOnstage); expect(find.byKey(secondKey), 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. 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('Destination hero is rebuilt midflight', (WidgetTester tester) async { final MutatingRoute route = new MutatingRoute(); await tester.pumpWidget(new MaterialApp( home: new Material( child: new ListView( children: <Widget>[ const Hero(tag: 'a', child: const Text('foo')), new Builder(builder: (BuildContext context) { return new FlatButton(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(new 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 = const 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, closeTo(curve.transform(0.25) * deltaHeight + initialHeight, epsilon) ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, closeTo(curve.transform(0.50) * deltaHeight + initialHeight, epsilon) ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, closeTo(curve.transform(0.75) * deltaHeight + initialHeight, epsilon) ); await tester.pump(duration * 0.25); expect( tester.getSize(find.byKey(secondKey)).height, closeTo(curve.transform(1.0) * deltaHeight + initialHeight, epsilon) ); }); testWidgets('Heroes are not interactive', (WidgetTester tester) async { final List<String> log = <String>[]; await tester.pumpWidget(new MaterialApp( home: new Center( child: new Hero( tag: 'foo', child: new GestureDetector( onTap: () { log.add('foo'); }, child: new Container( width: 100.0, height: 100.0, child: const Text('foo') ) ) ) ), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return new Align( alignment: Alignment.topLeft, child: new Hero( tag: 'foo', child: new GestureDetector( onTap: () { log.add('bar'); }, child: new Container( width: 100.0, height: 150.0, child: const 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)); expect(log, isEmpty); await tester.pump(const Duration(milliseconds: 10)); await tester.tap(find.text('foo', skipOffstage: false)); expect(log, isEmpty); await tester.tap(find.text('bar', skipOffstage: false)); expect(log, isEmpty); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('foo'), findsNothing); await tester.tap(find.text('bar', skipOffstage: 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(new MaterialApp( onGenerateRoute: (RouteSettings settings) { return new MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => new Hero(tag: 'test', child: new 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) // this is verifying that there's no crash // TODO(ianh): once https://github.com/flutter/flutter/issues/5631 is fixed, remove this line: await tester.pump(const Duration(hours: 1)); }); testWidgets('Overlapping starting and ending a hero transition works ok', (WidgetTester tester) async { await tester.pumpWidget(new MaterialApp( onGenerateRoute: (RouteSettings settings) { return new MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => new Hero(tag: 'test', child: new 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(); // this is verifying that there's no crash // TODO(ianh): once https://github.com/flutter/flutter/issues/5631 is fixed, remove this line: await tester.pump(const Duration(hours: 1)); }); testWidgets('One route, two heroes, same tag, throws', (WidgetTester tester) async { await tester.pumpWidget(new MaterialApp( home: new Material( child: new ListView( children: <Widget>[ const Hero(tag: 'a', child: const Text('a')), const Hero(tag: 'a', child: const Text('a too')), new Builder( builder: (BuildContext context) { return new FlatButton( child: const Text('push'), onPressed: () { Navigator.push(context, new PageRouteBuilder<void>( pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return const Text('fail'); }, )); }, ); }, ), ], ), ), )); await tester.tap(find.text('push')); await tester.pump(); expect(tester.takeException(), isFlutterError); }); testWidgets('Hero push transition interrupted by a pop', (WidgetTester tester) async { await tester.pumpWidget(new 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)); closeTo(tester.getSize(find.byKey(secondKey)).height - initialHeight, 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( new MaterialApp(routes: routes) ); // 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)); closeTo(tester.getSize(find.byKey(firstKey)).height - initialHeight, 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 = const Key('home hero'); const Key routeHeroKey = const Key('route hero'); bool routeIncludesHero = true; StateSetter heroCardSetState; // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( children: <Widget>[ new StatefulBuilder( builder: (BuildContext context, StateSetter setState) { heroCardSetState = setState; return new Card( child: routeIncludesHero ? new Hero(tag: 'H', child: new Container(key: routeHeroKey, height: 200.0, width: 200.0)) : new Container(height: 200.0, width: 200.0), ); }, ), new FlatButton( child: const Text('POP'), onPressed: () { Navigator.pop(context); } ), ], ) ); }, ); // Show a 100x100 Hero tagged 'H' with key homeHeroKey await tester.pumpWidget( new MaterialApp( home: new Scaffold( body: new Builder( builder: (BuildContext context) { // Navigator.push() needs context return new ListView( children: <Widget> [ new Card( child: new Hero(tag: 'H', child: new Container(key: homeHeroKey, height: 100.0, width: 100.0)), ), new FlatButton( 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 = const Key('home hero'); const Key routeHeroKey = const Key('route hero'); const Key routeContainerKey = const Key('route hero container'); // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 new Container( key: routeContainerKey, child: new Hero(tag: 'H', child: new Container(key: routeHeroKey, height: 200.0, width: 200.0)) ), new FlatButton( 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( new MaterialApp( home: new Scaffold( body: new Builder( builder: (BuildContext context) { // Navigator.push() needs context return new ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 new Container( child: new Hero(tag: 'H', child: new Container(key: homeHeroKey, height: 100.0, width: 100.0)), ), new FlatButton( 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)); 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 = const Key('home hero'); const Key routeHeroKey = const Key('route hero'); const Key routeContainerKey = const Key('route hero container'); // Show a 200x200 Hero tagged 'H', with key routeHeroKey final MaterialPageRoute<void> route = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( cacheExtent: 0.0, children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 new Container( key: routeContainerKey, child: new Hero(tag: 'H', child: new Container(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( new MaterialApp( home: new Scaffold( body: new Builder( builder: (BuildContext context) { // Navigator.push() needs context return new ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 new Container( child: new Hero(tag: 'H', child: new Container(key: homeHeroKey, height: 100.0, width: 100.0)), ), new FlatButton( 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)); 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 = const Key('AB hero'); const Key heroBCKey = const Key('BC hero'); // Show a 150x150 Hero tagged 'BC' final MaterialPageRoute<void> routeC = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( children: <Widget>[ // This container will appear at Y=0 new Container( child: new Hero(tag: 'BC', child: new Container(key: heroBCKey, height: 150.0)) ), const SizedBox(height: 800.0), ], ) ); }, ); // Show a height=200 Hero tagged 'AB' and a height=50 Hero tagged 'BC' final MaterialPageRoute<void> routeB = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( children: <Widget>[ const SizedBox(height: 100.0), // This container will appear at Y=100 new Container( child: new Hero(tag: 'AB', child: new Container(key: heroABKey, height: 200.0)) ), new FlatButton( child: const Text('PUSH C'), onPressed: () { Navigator.push(context, routeC); } ), new Container( child: new Hero(tag: 'BC', child: new Container(height: 150.0)) ), const SizedBox(height: 800.0), ], ) ); }, ); // Show a 100x100 Hero tagged 'AB' with key heroABKey await tester.pumpWidget( new MaterialApp( home: new Scaffold( body: new Builder( builder: (BuildContext context) { // Navigator.push() needs context return new ListView( children: <Widget> [ const SizedBox(height: 200.0), // This container will appear at Y=200 new Container( child: new Hero(tag: 'AB', child: new Container(height: 100.0, width: 100.0)), ), new FlatButton( 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); // One Opacity widget per Hero, only one now has opacity 0.0 final Iterable<RenderOpacity> renderers = tester.renderObjectList(find.byType(Opacity)); final Iterable<double> opacities = renderers.map((RenderOpacity r) => r.opacity); expect(opacities.singleWhere((double opacity) => opacity == 0.0), 0.0); // 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 = new MaterialPageRoute<void>( builder: (BuildContext context) { return new Material( child: new ListView( children: <Widget>[ const Card( child: const Hero( tag: 'H', child: const SizedBox( height: 200.0, child: const MyStatefulWidget(value: '456'), ), ), ), new FlatButton( child: const Text('POP'), onPressed: () { Navigator.pop(context); } ), ], ) ); }, ); await tester.pumpWidget( new MaterialApp( home: new Scaffold( body: new Builder( builder: (BuildContext context) { // Navigator.push() needs context return new ListView( children: <Widget> [ const Card( child: const Hero( tag: 'H', child: const SizedBox( height: 100.0, child: const MyStatefulWidget(value: '456'), ), ), ), new FlatButton( 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)); 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 new MaterialRectCenterArcTween(begin: begin, end: end); } final Map<String, WidgetBuilder> createRectTweenHeroRoutes = <String, WidgetBuilder>{ '/': (BuildContext context) => new Material( child: new Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ new Hero( tag: 'a', createRectTween: createRectTween, child: new Container(height: 100.0, width: 100.0, key: firstKey), ), new FlatButton( child: const Text('two'), onPressed: () { Navigator.pushNamed(context, '/two'); } ), ] ) ), '/two': (BuildContext context) => new Material( child: new Column( crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ new SizedBox( height: 200.0, child: new FlatButton( child: const Text('pop'), onPressed: () { Navigator.pop(context); } ), ), new Hero( tag: 'a', createRectTween: createRectTween, child: new Container(height: 200.0, width: 100.0, key: secondKey), ), ], ), ), }; await tester.pumpWidget(new MaterialApp(routes: createRectTweenHeroRoutes)); expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0)); const double epsilon = 0.001; const Duration duration = const Duration(milliseconds: 300); const Curve curve = Curves.fastOutSlowIn; final MaterialPointArcTween pushCenterTween = new 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 = new 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.flipped.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.flipped.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.flipped.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('Pop interrupts push, reverses flight', (WidgetTester tester) async { await tester.pumpWidget(new 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 = const 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, closeTo(x4, epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, closeTo(x3, epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, closeTo(x2, epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, closeTo(x1, epsilon)); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(secondKey)).dx, closeTo(x0, 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(closeTo(x4, epsilon))); await tester.pump(duration * 0.1); expect(tester.getTopLeft(find.byKey(firstKey)).dx, isNot(closeTo(x3, 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); }); }