blob: 7ed838ddbed9975ee87f6d8765204df729089950 [file] [log] [blame]
// 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);
});
}