diff --git a/packages/flutter/lib/src/animation/tween.dart b/packages/flutter/lib/src/animation/tween.dart index 6a43000545..2285b0021c 100644 --- a/packages/flutter/lib/src/animation/tween.dart +++ b/packages/flutter/lib/src/animation/tween.dart @@ -178,6 +178,22 @@ class Tween extends Animatable { String toString() => '$runtimeType($begin \u2192 $end)'; } +/// A [Tween] that evaluates its [parent] in reverse. +class ReverseTween extends Tween { + /// 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 parent; + + @override + T lerp(double t) => parent.lerp(1.0 - t); +} + /// An interpolation between two colors. /// /// This class specializes the interpolation of [Tween] to use diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index 51c2a0a294..cc7b6ad7bf 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -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 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] 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 heroRect; Animation _heroOpacity = kAlwaysCompleteAnimation; ProxyAnimation _proxyAnimation; _HeroFlightManifest manifest; OverlayEntry overlayEntry; bool _aborted = false; - RectTween _doCreateRectTween(Rect begin, Rect end) { + Tween _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(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]. HeroController({ this.createRectTween }); /// Used to create [RectTween]s that interpolate the position of heros in flight. diff --git a/packages/flutter/test/widgets/heroes_test.dart b/packages/flutter/test/widgets/heroes_test.dart index 97a5d44b04..d6da2c000d 100644 --- a/packages/flutter/test/widgets/heroes_test.dart +++ b/packages/flutter/test/widgets/heroes_test.dart @@ -26,6 +26,10 @@ final Map routes = { 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 routes = { ] ) ), + // 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: [ + 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 { @@ -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); + }); }