Commit c44aa266 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Aborted Hero push transitions should retrace their flight path (#12203)

parent f2d09601
...@@ -178,6 +178,22 @@ class Tween<T extends dynamic> extends Animatable<T> { ...@@ -178,6 +178,22 @@ class Tween<T extends dynamic> extends Animatable<T> {
String toString() => '$runtimeType($begin \u2192 $end)'; String toString() => '$runtimeType($begin \u2192 $end)';
} }
/// A [Tween] that evaluates its [parent] in reverse.
class ReverseTween<T> extends Tween<T> {
/// Construct a [Tween] that evaluates its [parent] in reverse.
ReverseTween(this.parent) : assert(parent != null), super(begin: parent.end, end: parent.begin);
/// This tween's value is the same as the parent's value evaluated in reverse.
///
/// This tween's [begin] is the parent's [end] and its [end] is the parent's
/// [begin]. The [lerp] method returns `parent.lerp(1.0 - t)` and its
/// [evaluate] method is similar.
final Tween<T> parent;
@override
T lerp(double t) => parent.lerp(1.0 - t);
}
/// An interpolation between two colors. /// An interpolation between two colors.
/// ///
/// This class specializes the interpolation of [Tween<Color>] to use /// This class specializes the interpolation of [Tween<Color>] to use
......
...@@ -18,7 +18,7 @@ import 'transitions.dart'; ...@@ -18,7 +18,7 @@ import 'transitions.dart';
/// This is typically used with a [HeroController] to provide an animation for /// This is typically used with a [HeroController] to provide an animation for
/// [Hero] positions that looks nicer than a linear movement. For example, see /// [Hero] positions that looks nicer than a linear movement. For example, see
/// [MaterialRectArcTween]. /// [MaterialRectArcTween].
typedef RectTween CreateRectTween(Rect begin, Rect end); typedef Tween<Rect> CreateRectTween(Rect begin, Rect end);
typedef void _OnFlightEnded(_HeroFlight flight); typedef void _OnFlightEnded(_HeroFlight flight);
...@@ -95,7 +95,7 @@ class Hero extends StatefulWidget { ...@@ -95,7 +95,7 @@ class Hero extends StatefulWidget {
/// route to the destination route. /// route to the destination route.
/// ///
/// A hero flight begins with the destination hero's [child] aligned with the /// A hero flight begins with the destination hero's [child] aligned with the
/// starting hero's child. The [RectTween] returned by this callback is used /// starting hero's child. The [Tween<Rect>] returned by this callback is used
/// to compute the hero's bounds as the flight animation's value goes from 0.0 /// to compute the hero's bounds as the flight animation's value goes from 0.0
/// to 1.0. /// to 1.0.
/// ///
...@@ -236,14 +236,14 @@ class _HeroFlight { ...@@ -236,14 +236,14 @@ class _HeroFlight {
final _OnFlightEnded onFlightEnded; final _OnFlightEnded onFlightEnded;
RectTween heroRect; Tween<Rect> heroRect;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation; Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation; ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest; _HeroFlightManifest manifest;
OverlayEntry overlayEntry; OverlayEntry overlayEntry;
bool _aborted = false; bool _aborted = false;
RectTween _doCreateRectTween(Rect begin, Rect end) { Tween<Rect> _doCreateRectTween(Rect begin, Rect end) {
final CreateRectTween createRectTween = manifest.toHero.widget.createRectTween ?? manifest.createRectTween; final CreateRectTween createRectTween = manifest.toHero.widget.createRectTween ?? manifest.createRectTween;
if (createRectTween != null) if (createRectTween != null)
return createRectTween(begin, end); return createRectTween(begin, end);
...@@ -268,11 +268,11 @@ class _HeroFlight { ...@@ -268,11 +268,11 @@ class _HeroFlight {
} }
} else if (toHeroBox.hasSize) { } else if (toHeroBox.hasSize) {
// The toHero has been laid out. If it's no longer where the hero animation is // The toHero has been laid out. If it's no longer where the hero animation is
// supposed to end up (heroRect.end) then recreate the heroRect tween. // supposed to end up then recreate the heroRect tween.
final RenderBox routeBox = manifest.toRoute.subtreeContext?.findRenderObject(); final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset heroOriginEnd = toHeroBox.localToGlobal(Offset.zero, ancestor: routeBox); final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (heroOriginEnd != heroRect.end.topLeft) { if (toHeroOrigin != heroRect.end.topLeft) {
final Rect heroRectEnd = heroOriginEnd & heroRect.end.size; final Rect heroRectEnd = toHeroOrigin & heroRect.end.size;
heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd); heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd);
} }
} }
...@@ -359,9 +359,13 @@ class _HeroFlight { ...@@ -359,9 +359,13 @@ class _HeroFlight {
assert(manifest.fromRoute == newManifest.toRoute); assert(manifest.fromRoute == newManifest.toRoute);
assert(manifest.toRoute == newManifest.fromRoute); assert(manifest.toRoute == newManifest.fromRoute);
// The same heroRect tween is used in reverse, rather than creating
// a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
// That's because tweens like MaterialRectArcTween may create a different
// path for swapped begin and end parameters. We want the pop flight
// path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation); _proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
heroRect = new ReverseTween<Rect>(heroRect);
heroRect = _doCreateRectTween(heroRect.end, heroRect.begin);
} else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) { } else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) {
// A pop flight was interrupted by a push. // A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward); assert(newManifest.animation.status == AnimationStatus.forward);
...@@ -378,6 +382,7 @@ class _HeroFlight { ...@@ -378,6 +382,7 @@ class _HeroFlight {
newManifest.toHero.startFlight(); newManifest.toHero.startFlight();
heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context)); heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context));
} else { } else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRect = _doCreateRectTween(heroRect.end, heroRect.begin); heroRect = _doCreateRectTween(heroRect.end, heroRect.begin);
} }
} else { } else {
...@@ -425,7 +430,7 @@ class HeroController extends NavigatorObserver { ...@@ -425,7 +430,7 @@ class HeroController extends NavigatorObserver {
/// Creates a hero controller with the given [RectTween] constructor if any. /// Creates a hero controller with the given [RectTween] constructor if any.
/// ///
/// The [createRectTween] argument is optional. If null, the controller uses a /// The [createRectTween] argument is optional. If null, the controller uses a
/// linear [RectTween]. /// linear [Tween<Rect>].
HeroController({ this.createRectTween }); HeroController({ this.createRectTween });
/// Used to create [RectTween]s that interpolate the position of heros in flight. /// Used to create [RectTween]s that interpolate the position of heros in flight.
......
...@@ -26,6 +26,10 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ ...@@ -26,6 +26,10 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
child: const Text('two'), child: const Text('two'),
onPressed: () { Navigator.pushNamed(context, '/two'); } onPressed: () { Navigator.pushNamed(context, '/two'); }
), ),
new FlatButton(
child: const Text('twoInset'),
onPressed: () { Navigator.pushNamed(context, '/twoInset'); }
),
] ]
) )
), ),
...@@ -47,6 +51,34 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ ...@@ -47,6 +51,34 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
] ]
) )
), ),
// 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<Null> { class ThreeRoute extends MaterialPageRoute<Null> {
...@@ -1119,5 +1151,96 @@ void main() { ...@@ -1119,5 +1151,96 @@ void main() {
expect(tester.getCenter(find.byKey(firstKey)), const Offset(50.0, 50.0)); 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.
final double epsilon = 0.001;
final 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);
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment