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> {
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.
///
/// This class specializes the interpolation of [Tween<Color>] to use
......
......@@ -18,7 +18,7 @@ import 'transitions.dart';
/// This is typically used with a [HeroController] to provide an animation for
/// [Hero] positions that looks nicer than a linear movement. For example, see
/// [MaterialRectArcTween].
typedef RectTween CreateRectTween(Rect begin, Rect end);
typedef Tween<Rect> CreateRectTween(Rect begin, Rect end);
typedef void _OnFlightEnded(_HeroFlight flight);
......@@ -95,7 +95,7 @@ class Hero extends StatefulWidget {
/// route to the destination route.
///
/// 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 1.0.
///
......@@ -236,14 +236,14 @@ class _HeroFlight {
final _OnFlightEnded onFlightEnded;
RectTween heroRect;
Tween<Rect> heroRect;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest;
OverlayEntry overlayEntry;
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;
if (createRectTween != null)
return createRectTween(begin, end);
......@@ -268,11 +268,11 @@ class _HeroFlight {
}
} else if (toHeroBox.hasSize) {
// 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.
final RenderBox routeBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset heroOriginEnd = toHeroBox.localToGlobal(Offset.zero, ancestor: routeBox);
if (heroOriginEnd != heroRect.end.topLeft) {
final Rect heroRectEnd = heroOriginEnd & heroRect.end.size;
// supposed to end up then recreate the heroRect tween.
final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (toHeroOrigin != heroRect.end.topLeft) {
final Rect heroRectEnd = toHeroOrigin & heroRect.end.size;
heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd);
}
}
......@@ -359,9 +359,13 @@ class _HeroFlight {
assert(manifest.fromRoute == newManifest.toRoute);
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);
heroRect = _doCreateRectTween(heroRect.end, heroRect.begin);
heroRect = new ReverseTween<Rect>(heroRect);
} else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) {
// A pop flight was interrupted by a push.
assert(newManifest.animation.status == AnimationStatus.forward);
......@@ -378,6 +382,7 @@ class _HeroFlight {
newManifest.toHero.startFlight();
heroRect = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context));
} else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRect = _doCreateRectTween(heroRect.end, heroRect.begin);
}
} else {
......@@ -425,7 +430,7 @@ class HeroController extends NavigatorObserver {
/// Creates a hero controller with the given [RectTween] constructor if any.
///
/// The [createRectTween] argument is optional. If null, the controller uses a
/// linear [RectTween].
/// linear [Tween<Rect>].
HeroController({ this.createRectTween });
/// Used to create [RectTween]s that interpolate the position of heros in flight.
......
......@@ -26,6 +26,10 @@ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
child: const Text('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>{
]
)
),
// 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> {
......@@ -1119,5 +1151,96 @@ void main() {
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