Fade through transition (#52)

diff --git a/packages/animations/lib/animations.dart b/packages/animations/lib/animations.dart
index f58279d..d34f54e 100644
--- a/packages/animations/lib/animations.dart
+++ b/packages/animations/lib/animations.dart
@@ -2,4 +2,5 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+export 'src/fade_through_transition.dart';
 export 'src/open_container.dart';
diff --git a/packages/animations/lib/src/fade_through_transition.dart b/packages/animations/lib/src/fade_through_transition.dart
new file mode 100644
index 0000000..51c17a7
--- /dev/null
+++ b/packages/animations/lib/src/fade_through_transition.dart
@@ -0,0 +1,420 @@
+// Copyright 2019 The Flutter 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/material.dart';
+
+/// Used by [PageTransitionsTheme] to define a page route transition animation
+/// in which the outgoing page fades out, then the incoming page fades in and
+/// scale up.
+///
+/// This pattern is recommended for a transition between UI elements that do not
+/// have a strong relationship to one another.
+///
+/// Scale is only applied to incoming elements to emphasize new content over
+/// old.
+///
+/// The following example shows how the FadeThroughPageTransitionsBuilder can
+/// be used in a [PageTransitionsTheme] to change the default transitions
+/// of [MaterialPageRoute]s.
+///
+/// ```dart
+/// MaterialApp(
+///   theme: ThemeData(
+///     pageTransitionsTheme: PageTransitionsTheme(
+///       builders: {
+///         TargetPlatform.android: FadeThroughPageTransitionsBuilder(),
+///         TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),
+///       },
+///     ),
+///   ),
+///   routes: {
+///     '/': (BuildContext context) {
+///       return Container(
+///         color: Colors.red,
+///         child: Center(
+///           child: MaterialButton(
+///             child: Text('Push route'),
+///             onPressed: () {
+///               Navigator.of(context).pushNamed('/a');
+///             },
+///           ),
+///         ),
+///       );
+///     },
+///     '/a' : (BuildContext context) {
+///       return Container(
+///         color: Colors.blue,
+///         child: Center(
+///           child: MaterialButton(
+///             child: Text('Pop route'),
+///             onPressed: () {
+///               Navigator.of(context).pop();
+///             },
+///           ),
+///         ),
+///       );
+///     },
+///   },
+/// );
+/// ```
+class FadeThroughPageTransitionsBuilder extends PageTransitionsBuilder {
+  /// Creates a [FadeThroughPageTransitionsBuilder].
+  const FadeThroughPageTransitionsBuilder();
+
+  @override
+  Widget buildTransitions<T>(
+    PageRoute<T> route,
+    BuildContext context,
+    Animation<double> animation,
+    Animation<double> secondaryAnimation,
+    Widget child,
+  ) {
+    return FadeThroughTransition(
+      animation: animation,
+      secondaryAnimation: secondaryAnimation,
+      child: child,
+    );
+  }
+}
+
+/// Defines a transition in which outgoing elements fade out, then incoming
+/// elements fade in and scale up.
+///
+/// The fade through pattern provides a transition animation between UI elements
+/// that do not have a strong relationship to one another. As an example, the
+/// [BottomNavigationBar] may use this animation to transition the currently
+/// displayed content when a new [BottomNavigationBarItem] is selected.
+///
+/// Scale is only applied to incoming elements to emphasize new content over
+/// old.
+///
+/// Consider using [FadeThroughPageTransitionsBuilder] within a
+/// [PageTransitionsTheme] if you want to apply this kind of transition to
+/// [MaterialPageRoute] transitions within a Navigator (see
+/// [FadeThroughPageTransitionsBuilder] for some example code). Or use this transition
+/// directly in a [PageTransitionSwitcher.transitionBuilder] to transition
+/// from one widget to another as seen in the following example:
+///
+/// ```dart
+///  int _selectedIndex = 0;
+///
+///  final List<Color> _colors = [Colors.blue, Colors.red, Colors.yellow];
+///
+///  @override
+///  Widget build(BuildContext context) {
+///    return Scaffold(
+///      appBar: AppBar(
+///        title: const Text('Switcher Sample'),
+///      ),
+///      body: PageTransitionSwitcher(
+///        transitionBuilder: (
+///          Widget child,
+///          Animation<double> primaryAnimation,
+///          Animation<double> secondaryAnimation,
+///        ) {
+///          return FadeThroughTransition(
+///            child: child,
+///            animation: primaryAnimation,
+///            secondaryAnimation: secondaryAnimation,
+///          );
+///        },
+///        child: Container(
+///          key: ValueKey<int>(_selectedIndex),
+///          color: _colors[_selectedIndex],
+///        ),
+///      ),
+///      bottomNavigationBar: BottomNavigationBar(
+///        items: const <BottomNavigationBarItem>[
+///          BottomNavigationBarItem(
+///            icon: Icon(Icons.home),
+///            title: Text('Blue'),
+///          ),
+///          BottomNavigationBarItem(
+///            icon: Icon(Icons.business),
+///            title: Text('Red'),
+///          ),
+///          BottomNavigationBarItem(
+///            icon: Icon(Icons.school),
+///            title: Text('Yellow'),
+///          ),
+///        ],
+///        currentIndex: _selectedIndex,
+///        selectedItemColor: Colors.amber[800],
+///        onTap: (int index) {
+///          setState(() {
+///            _selectedIndex = index;
+///          });
+///        },
+///      ),
+///    );
+///  }
+/// ```
+class FadeThroughTransition extends StatefulWidget {
+  /// Creates a [FadeThroughTransition].
+  ///
+  /// The [animation] and [secondaryAnimation] argument are required and must
+  /// not be null.
+  const FadeThroughTransition({
+    @required this.animation,
+    @required this.secondaryAnimation,
+    this.child,
+  })  : assert(animation != null),
+        assert(secondaryAnimation != null);
+
+  /// The animation that drives the [child]'s entrance and exit.
+  ///
+  /// See also:
+  ///
+  ///  * [TransitionRoute.animate], which is the value given to this property
+  ///    when the [FadeThroughTransition] is used as a page transition.
+  final Animation<double> animation;
+
+  /// The animation that transitions [child] when new content is pushed on top
+  /// of it.
+  ///
+  /// See also:
+  ///
+  ///  * [TransitionRoute.secondaryAnimation], which is the value given to this
+  //     property when the [FadeThroughTransition] is used as a page transition.
+  final Animation<double> secondaryAnimation;
+
+  /// The widget below this widget in the tree.
+  ///
+  /// This widget will transition in and out as driven by [animation] and
+  /// [secondaryAnimation].
+  final Widget child;
+
+  @override
+  State<FadeThroughTransition> createState() => _FadeThroughTransitionState();
+}
+
+class _FadeThroughTransitionState extends State<FadeThroughTransition> {
+  AnimationStatus _effectiveAnimationStatus;
+  AnimationStatus _effectiveSecondaryAnimationStatus;
+
+  @override
+  void initState() {
+    super.initState();
+    _effectiveAnimationStatus = widget.animation.status;
+    _effectiveSecondaryAnimationStatus = widget.secondaryAnimation.status;
+    widget.animation.addStatusListener(_animationListener);
+    widget.secondaryAnimation.addStatusListener(_secondaryAnimationListener);
+  }
+
+  void _animationListener(AnimationStatus animationStatus) {
+    _effectiveAnimationStatus = _calculateEffectiveAnimationStatus(
+      lastEffective: _effectiveAnimationStatus,
+      current: animationStatus,
+    );
+  }
+
+  void _secondaryAnimationListener(AnimationStatus animationStatus) {
+    _effectiveSecondaryAnimationStatus = _calculateEffectiveAnimationStatus(
+      lastEffective: _effectiveSecondaryAnimationStatus,
+      current: animationStatus,
+    );
+  }
+
+  // When a transition is interrupted midway we just want to play the ongoing
+  // animation in reverse. Switching to the actual reverse transition would
+  // yield a disjoint experience since the forward and reverse transitions are
+  // very different.
+  AnimationStatus _calculateEffectiveAnimationStatus({
+    @required AnimationStatus lastEffective,
+    @required AnimationStatus current,
+  }) {
+    assert(current != null);
+    assert(lastEffective != null);
+    switch (current) {
+      case AnimationStatus.dismissed:
+      case AnimationStatus.completed:
+        return current;
+      case AnimationStatus.forward:
+        switch (lastEffective) {
+          case AnimationStatus.dismissed:
+          case AnimationStatus.completed:
+          case AnimationStatus.forward:
+            return current;
+          case AnimationStatus.reverse:
+            return lastEffective;
+        }
+        break;
+      case AnimationStatus.reverse:
+        switch (lastEffective) {
+          case AnimationStatus.dismissed:
+          case AnimationStatus.completed:
+          case AnimationStatus.reverse:
+            return current;
+          case AnimationStatus.forward:
+            return lastEffective;
+        }
+        break;
+    }
+    return null; // unreachable
+  }
+
+  @override
+  void didUpdateWidget(FadeThroughTransition oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.animation != widget.animation) {
+      oldWidget.animation.removeStatusListener(_animationListener);
+      widget.animation.addStatusListener(_animationListener);
+      _animationListener(widget.animation.status);
+    }
+    if (oldWidget.secondaryAnimation != widget.secondaryAnimation) {
+      oldWidget.secondaryAnimation
+          .removeStatusListener(_secondaryAnimationListener);
+      widget.secondaryAnimation.addStatusListener(_secondaryAnimationListener);
+      _secondaryAnimationListener(widget.secondaryAnimation.status);
+    }
+  }
+
+  @override
+  void dispose() {
+    widget.animation.removeStatusListener(_animationListener);
+    widget.secondaryAnimation.removeStatusListener(_secondaryAnimationListener);
+    super.dispose();
+  }
+
+  static final Tween<double> _flippedTween = Tween<double>(
+    begin: 1.0,
+    end: 0.0,
+  );
+  static Animation<double> _flip(Animation<double> animation) {
+    return _flippedTween.animate(animation);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: widget.animation,
+      builder: (BuildContext context, Widget child) {
+        assert(_effectiveAnimationStatus != null);
+        switch (_effectiveAnimationStatus) {
+          case AnimationStatus.forward:
+            return _ZoomedFadeIn(
+              animation: widget.animation,
+              child: child,
+            );
+          case AnimationStatus.dismissed:
+          case AnimationStatus.reverse:
+          case AnimationStatus.completed:
+            return _FadeOut(
+              animation: _flip(widget.animation),
+              child: child,
+            );
+        }
+        return null; // unreachable
+      },
+      child: Container(
+        color: Theme.of(context).canvasColor,
+        child: AnimatedBuilder(
+          animation: widget.secondaryAnimation,
+          builder: (BuildContext context, Widget child) {
+            assert(_effectiveSecondaryAnimationStatus != null);
+            switch (_effectiveSecondaryAnimationStatus) {
+              case AnimationStatus.forward:
+                return _FadeOut(
+                  child: child,
+                  animation: widget.secondaryAnimation,
+                );
+              case AnimationStatus.dismissed:
+              case AnimationStatus.reverse:
+              case AnimationStatus.completed:
+                return _ZoomedFadeIn(
+                  animation: _flip(widget.secondaryAnimation),
+                  child: child,
+                );
+            }
+            return null; // unreachable
+          },
+          child: widget.child,
+        ),
+      ),
+    );
+  }
+}
+
+class _ZoomedFadeIn extends StatelessWidget {
+  const _ZoomedFadeIn({
+    this.child,
+    this.animation,
+  });
+
+  final Widget child;
+  final Animation<double> animation;
+
+  static final CurveTween _inCurve = CurveTween(
+    curve: const Cubic(0.0, 0.0, 0.2, 1.0),
+  );
+  static final TweenSequence<double> _scaleIn = TweenSequence<double>(
+    <TweenSequenceItem<double>>[
+      TweenSequenceItem<double>(
+        tween: ConstantTween<double>(0.95),
+        weight: 6 / 20,
+      ),
+      TweenSequenceItem<double>(
+        tween: Tween<double>(begin: 0.95, end: 1.0).chain(_inCurve),
+        weight: 14 / 20,
+      ),
+    ],
+  );
+  static final TweenSequence<double> _fadeInOpacity = TweenSequence<double>(
+    <TweenSequenceItem<double>>[
+      TweenSequenceItem<double>(
+        tween: ConstantTween<double>(0.0),
+        weight: 6 / 20,
+      ),
+      TweenSequenceItem<double>(
+        tween: Tween<double>(begin: 0.0, end: 1.0).chain(_inCurve),
+        weight: 14 / 20,
+      ),
+    ],
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    return FadeTransition(
+      opacity: _fadeInOpacity.animate(animation),
+      child: ScaleTransition(
+        scale: _scaleIn.animate(animation),
+        child: child,
+      ),
+    );
+  }
+}
+
+class _FadeOut extends StatelessWidget {
+  const _FadeOut({
+    this.child,
+    this.animation,
+  });
+
+  final Widget child;
+  final Animation<double> animation;
+
+  static final CurveTween _outCurve = CurveTween(
+    curve: const Cubic(0.4, 0.0, 1.0, 1.0),
+  );
+  static final TweenSequence<double> _fadeOutOpacity = TweenSequence<double>(
+    <TweenSequenceItem<double>>[
+      TweenSequenceItem<double>(
+        tween: Tween<double>(begin: 1.0, end: 0.0).chain(_outCurve),
+        weight: 6 / 20,
+      ),
+      TweenSequenceItem<double>(
+        tween: ConstantTween<double>(0.0),
+        weight: 14 / 20,
+      ),
+    ],
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    return FadeTransition(
+      opacity: _fadeOutOpacity.animate(animation),
+      child: child,
+    );
+  }
+}
diff --git a/packages/animations/test/fade_through_transition_test.dart b/packages/animations/test/fade_through_transition_test.dart
new file mode 100644
index 0000000..ba2408a
--- /dev/null
+++ b/packages/animations/test/fade_through_transition_test.dart
@@ -0,0 +1,444 @@
+// Copyright 2019 The Flutter 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:animations/src/fade_through_transition.dart';
+import 'package:flutter/animation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/widgets.dart';
+
+void main() {
+  testWidgets(
+      'FadeThroughPageTransitionsBuilder builds a FadeThroughTransition',
+      (WidgetTester tester) async {
+    final AnimationController animation = AnimationController(
+      vsync: const TestVSync(),
+    );
+    final AnimationController secondaryAnimation = AnimationController(
+      vsync: const TestVSync(),
+    );
+
+    await tester.pumpWidget(
+        const FadeThroughPageTransitionsBuilder().buildTransitions<void>(
+      null,
+      null,
+      animation,
+      secondaryAnimation,
+      const Placeholder(),
+    ));
+
+    expect(find.byType(FadeThroughTransition), findsOneWidget);
+  });
+
+  testWidgets('FadeThroughTransition runs forward',
+      (WidgetTester tester) async {
+    final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>();
+    const String bottomRoute = '/';
+    const String topRoute = '/a';
+
+    await tester.pumpWidget(
+      _TestWidget(
+        navigatorKey: navigator,
+      ),
+    );
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 1.0);
+    expect(find.text(topRoute), findsNothing);
+
+    navigator.currentState.pushNamed(topRoute);
+    await tester.pump();
+    await tester.pump();
+
+    // Bottom route is full size and fully visible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 1.0);
+    // top route is at 95% of full size and not visible yet.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 0.95);
+    expect(_getOpacity(topRoute, tester), 0.0);
+
+    // Jump to half-way through the fade out (total duration is 300ms, 6/12th of
+    // that are fade out, so half-way is 300 * 6/12 / 2 = 45ms.
+    await tester.pump(const Duration(milliseconds: 45));
+    // Bottom route is fading out.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    final double bottomOpacity = _getOpacity(bottomRoute, tester);
+    expect(bottomOpacity, lessThan(1.0));
+    expect(bottomOpacity, greaterThan(0.0));
+    // Top route is still invisible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 0.95);
+    expect(_getOpacity(topRoute, tester), 0.0);
+
+    // Let's jump to the end of the fade-out.
+    await tester.pump(const Duration(milliseconds: 45));
+    // Bottom route is completely faded out.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+    // Top route is still invisible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 0.95);
+    expect(_getOpacity(topRoute, tester), 0.0);
+
+    // Let's jump to the middle of the fade-in.
+    await tester.pump(const Duration(milliseconds: 105));
+    // Bottom route is not visible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+    // Top route is fading/scaling in.
+    expect(find.text(topRoute), findsOneWidget);
+    final double topScale = _getScale(topRoute, tester);
+    final double topOpacity = _getOpacity(topRoute, tester);
+    expect(topScale, greaterThan(0.95));
+    expect(topScale, lessThan(1.0));
+    expect(topOpacity, greaterThan(0.0));
+    expect(topOpacity, lessThan(1.0));
+
+    // Let's jump to the end of the animation.
+    await tester.pump(const Duration(milliseconds: 105));
+    // Bottom route is not visible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+    // Top route fully scaled in and visible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 1.0);
+
+    await tester.pump(const Duration(milliseconds: 1));
+    expect(find.text(bottomRoute), findsNothing);
+    expect(find.text(topRoute), findsOneWidget);
+  });
+
+  testWidgets('FadeThroughTransition runs backwards',
+      (WidgetTester tester) async {
+    final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>();
+    const String bottomRoute = '/';
+    const String topRoute = '/a';
+
+    await tester.pumpWidget(
+      _TestWidget(
+        navigatorKey: navigator,
+      ),
+    );
+    navigator.currentState.pushNamed('/a');
+    await tester.pumpAndSettle();
+
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 1.0);
+    expect(find.text(bottomRoute), findsNothing);
+
+    navigator.currentState.pop();
+    await tester.pump();
+
+    // Top route is full size and fully visible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 1.0);
+    // Bottom route is at 95% of full size and not visible yet.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 0.95);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+
+    // Jump to half-way through the fade out (total duration is 300ms, 6/12th of
+    // that are fade out, so half-way is 300 * 6/12 / 2 = 45ms.
+    await tester.pump(const Duration(milliseconds: 45));
+    // Bottom route is fading out.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    final double topOpacity = _getOpacity(topRoute, tester);
+    expect(topOpacity, lessThan(1.0));
+    expect(topOpacity, greaterThan(0.0));
+    // Top route is still invisible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 0.95);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+
+    // Let's jump to the end of the fade-out.
+    await tester.pump(const Duration(milliseconds: 45));
+    // Bottom route is completely faded out.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 0.0);
+    // Top route is still invisible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(
+      _getScale(bottomRoute, tester),
+      moreOrLessEquals(0.95, epsilon: 0.005),
+    );
+    expect(
+      _getOpacity(bottomRoute, tester),
+      moreOrLessEquals(0.0, epsilon: 0.005),
+    );
+
+    // Let's jump to the middle of the fade-in.
+    await tester.pump(const Duration(milliseconds: 105));
+    // Bottom route is not visible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 0.0);
+    // Top route is fading/scaling in.
+    expect(find.text(bottomRoute), findsOneWidget);
+    final double bottomScale = _getScale(bottomRoute, tester);
+    final double bottomOpacity = _getOpacity(bottomRoute, tester);
+    expect(bottomScale, greaterThan(0.96));
+    expect(bottomScale, lessThan(1.0));
+    expect(bottomOpacity, greaterThan(0.1));
+    expect(bottomOpacity, lessThan(1.0));
+
+    // Let's jump to the end of the animation.
+    await tester.pump(const Duration(milliseconds: 105));
+    // Bottom route is not visible.
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 1.0);
+    expect(_getOpacity(topRoute, tester), 0.0);
+    // Top route fully scaled in and visible.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 1.0);
+
+    await tester.pump(const Duration(milliseconds: 1));
+    expect(find.text(topRoute), findsNothing);
+    expect(find.text(bottomRoute), findsOneWidget);
+  });
+
+  testWidgets('FadeThroughTransition does not jump when interrupted',
+      (WidgetTester tester) async {
+    final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>();
+    const String bottomRoute = '/';
+    const String topRoute = '/a';
+
+    await tester.pumpWidget(
+      _TestWidget(
+        navigatorKey: navigator,
+      ),
+    );
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(find.text(topRoute), findsNothing);
+
+    navigator.currentState.pushNamed(topRoute);
+    await tester.pump();
+
+    // Jump to halfway point of transition.
+    await tester.pump(const Duration(milliseconds: 150));
+    // Bottom route is fully faded out.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+    // Top route is fading/scaling in.
+    expect(find.text(topRoute), findsOneWidget);
+    final double topScale = _getScale(topRoute, tester);
+    final double topOpacity = _getOpacity(topRoute, tester);
+    expect(topScale, greaterThan(0.95));
+    expect(topScale, lessThan(1.0));
+    expect(topOpacity, greaterThan(0.0));
+    expect(topOpacity, lessThan(1.0));
+
+    // Interrupt the transition with a pop.
+    navigator.currentState.pop();
+    await tester.pump();
+    // Noting changed.
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 0.0);
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), topScale);
+    expect(_getOpacity(topRoute, tester), topOpacity);
+
+    // Jump to the halfway point.
+    await tester.pump(const Duration(milliseconds: 75));
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    final double bottomOpacity = _getOpacity(bottomRoute, tester);
+    expect(bottomOpacity, greaterThan(0.0));
+    expect(bottomOpacity, lessThan(1.0));
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), lessThan(topScale));
+    expect(_getOpacity(topRoute, tester), lessThan(topOpacity));
+
+    // Jump to the end.
+    await tester.pump(const Duration(milliseconds: 75));
+    expect(find.text(bottomRoute), findsOneWidget);
+    expect(_getScale(bottomRoute, tester), 1.0);
+    expect(_getOpacity(bottomRoute, tester), 1.0);
+    expect(find.text(topRoute), findsOneWidget);
+    expect(_getScale(topRoute, tester), 0.95);
+    expect(_getOpacity(topRoute, tester), 0.0);
+
+    await tester.pump(const Duration(milliseconds: 1));
+    expect(find.text(topRoute), findsNothing);
+    expect(find.text(bottomRoute), findsOneWidget);
+  });
+
+  testWidgets('State is not lost when transitioning',
+      (WidgetTester tester) async {
+    final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>();
+    const String bottomRoute = '/';
+    const String topRoute = '/a';
+
+    await tester.pumpWidget(
+      _TestWidget(
+        navigatorKey: navigator,
+        contentBuilder: (RouteSettings settings) {
+          return _StatefulTestWidget(
+            key: ValueKey<String>(settings.name),
+            name: settings.name,
+          );
+        },
+      ),
+    );
+
+    final _StatefulTestWidgetState bottomState =
+        tester.state(find.byKey(const ValueKey<String>(bottomRoute)));
+    expect(bottomState.widget.name, bottomRoute);
+
+    navigator.currentState.pushNamed(topRoute);
+    await tester.pump();
+    await tester.pump();
+
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(bottomRoute))),
+      bottomState,
+    );
+    final _StatefulTestWidgetState topState = tester.state(
+      find.byKey(const ValueKey<String>(topRoute)),
+    );
+    expect(topState.widget.name, topRoute);
+
+    await tester.pump(const Duration(milliseconds: 150));
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(bottomRoute))),
+      bottomState,
+    );
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(topRoute))),
+      topState,
+    );
+
+    await tester.pumpAndSettle();
+    expect(
+      tester.state(find.byKey(
+        const ValueKey<String>(bottomRoute),
+        skipOffstage: false,
+      )),
+      bottomState,
+    );
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(topRoute))),
+      topState,
+    );
+
+    navigator.currentState.pop();
+    await tester.pump();
+
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(bottomRoute))),
+      bottomState,
+    );
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(topRoute))),
+      topState,
+    );
+
+    await tester.pump(const Duration(milliseconds: 150));
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(bottomRoute))),
+      bottomState,
+    );
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(topRoute))),
+      topState,
+    );
+
+    await tester.pumpAndSettle();
+    expect(
+      tester.state(find.byKey(const ValueKey<String>(bottomRoute))),
+      bottomState,
+    );
+    expect(find.byKey(const ValueKey<String>(topRoute)), findsNothing);
+  });
+}
+
+double _getOpacity(String key, WidgetTester tester) {
+  final Finder finder = find.ancestor(
+    of: find.byKey(ValueKey<String>(key)),
+    matching: find.byType(FadeTransition),
+  );
+  return tester.widgetList(finder).fold<double>(1.0, (double a, Widget widget) {
+    final FadeTransition transition = widget;
+    return a * transition.opacity.value;
+  });
+}
+
+double _getScale(String key, WidgetTester tester) {
+  final Finder finder = find.ancestor(
+    of: find.byKey(ValueKey<String>(key)),
+    matching: find.byType(ScaleTransition),
+  );
+  return tester.widgetList(finder).fold<double>(1.0, (double a, Widget widget) {
+    final ScaleTransition transition = widget;
+    return a * transition.scale.value;
+  });
+}
+
+class _TestWidget extends StatelessWidget {
+  const _TestWidget({this.navigatorKey, this.contentBuilder});
+
+  final Key navigatorKey;
+  final _ContentBuilder contentBuilder;
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      navigatorKey: navigatorKey,
+      theme: ThemeData(
+        platform: TargetPlatform.android,
+        pageTransitionsTheme: const PageTransitionsTheme(
+          builders: <TargetPlatform, PageTransitionsBuilder>{
+            TargetPlatform.android: FadeThroughPageTransitionsBuilder(),
+          },
+        ),
+      ),
+      onGenerateRoute: (RouteSettings settings) {
+        return MaterialPageRoute<void>(
+          settings: settings,
+          builder: (BuildContext context) {
+            return contentBuilder != null
+                ? contentBuilder(settings)
+                : Container(
+                    child: Center(
+                      key: ValueKey<String>(settings.name),
+                      child: Text(settings.name),
+                    ),
+                  );
+          },
+        );
+      },
+    );
+  }
+}
+
+class _StatefulTestWidget extends StatefulWidget {
+  const _StatefulTestWidget({Key key, this.name}) : super(key: key);
+
+  final String name;
+
+  @override
+  State<_StatefulTestWidget> createState() => _StatefulTestWidgetState();
+}
+
+class _StatefulTestWidgetState extends State<_StatefulTestWidget> {
+  @override
+  Widget build(BuildContext context) {
+    return Text(widget.name);
+  }
+}
+
+typedef _ContentBuilder = Widget Function(RouteSettings settings);