diff --git a/packages/flutter/lib/src/material/page_transitions_theme.dart b/packages/flutter/lib/src/material/page_transitions_theme.dart index 2b2c5233f2..05ab2b6d39 100644 --- a/packages/flutter/lib/src/material/page_transitions_theme.dart +++ b/packages/flutter/lib/src/material/page_transitions_theme.dart @@ -150,19 +150,19 @@ class _OpenUpwardsPageTransition extends StatelessWidget { // Zooms and fades a new page in, zooming out the previous page. This transition // is designed to match the Android 10 activity transition. -class _ZoomPageTransition extends StatefulWidget { +class _ZoomPageTransition extends StatelessWidget { + /// Creates a [_ZoomPageTransition]. + /// + /// The [animation] and [secondaryAnimation] argument are required and must + /// not be null. const _ZoomPageTransition({ Key key, - this.animation, - this.secondaryAnimation, + @required this.animation, + @required this.secondaryAnimation, this.child, - }) : super(key: key); - - // The scrim obscures the old page by becoming increasingly opaque. - static final Tween _scrimOpacityTween = Tween( - begin: 0.0, - end: 0.60, - ); + }) : assert(animation != null), + assert(secondaryAnimation != null), + super(key: key); // A curve sequence that is similar to the 'fastOutExtraSlowIn' curve used in // the native transition. @@ -179,132 +179,207 @@ class _ZoomPageTransition extends StatefulWidget { ), ]; static final TweenSequence _scaleCurveSequence = TweenSequence(fastOutExtraSlowInTweenSequenceItems); - static final FlippedTweenSequence _flippedScaleCurveSequence = FlippedTweenSequence(fastOutExtraSlowInTweenSequenceItems); + /// The animation that drives the [child]'s entrance and exit. + /// + /// See also: + /// + /// * [TransitionRoute.animation], which is the value given to this property + /// when the [_ZoomPageTransition] is used as a page transition. final Animation 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 [_ZoomPageTransition] is used as a page transition. final Animation 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 - __ZoomPageTransitionState createState() => __ZoomPageTransitionState(); + Widget build(BuildContext context) { + return DualTransitionBuilder( + animation: animation, + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return _ZoomEnterTransition( + animation: animation, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return _ZoomExitTransition( + animation: animation, + reverse: true, + child: child, + ); + }, + child: DualTransitionBuilder( + animation: ReverseAnimation(secondaryAnimation), + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return _ZoomEnterTransition( + animation: animation, + reverse: true, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return _ZoomExitTransition( + animation: animation, + child: child, + ); + }, + child: child, + ), + ); + } } -class __ZoomPageTransitionState extends State<_ZoomPageTransition> { - AnimationStatus _currentAnimationStatus; - AnimationStatus _lastAnimationStatus; +class _ZoomEnterTransition extends StatelessWidget { + const _ZoomEnterTransition({ + Key key, + @required this.animation, + this.reverse = false, + this.child, + }) : assert(animation != null), + assert(reverse != null), + super(key: key); - @override - void initState() { - super.initState(); - widget.animation.addStatusListener((AnimationStatus animationStatus) { - _lastAnimationStatus = _currentAnimationStatus; - _currentAnimationStatus = animationStatus; - }); - } + final Animation animation; + final Widget child; + final bool reverse; - // This check ensures that the animation reverses the original animation if - // the transition were interrupted midway. This prevents a disjointed - // experience since the reverse animation uses different fade and scaling - // curves. - bool get _transitionWasInterrupted { - bool wasInProgress = false; - bool isInProgress = false; + static final Animatable _fadeInTransition = Tween( + begin: 0.0, + end: 1.00, + ).chain(CurveTween(curve: const Interval(0.125, 0.250))); - switch (_currentAnimationStatus) { - case AnimationStatus.completed: - case AnimationStatus.dismissed: - isInProgress = false; - break; - case AnimationStatus.forward: - case AnimationStatus.reverse: - isInProgress = true; - break; - } - switch (_lastAnimationStatus) { - case AnimationStatus.completed: - case AnimationStatus.dismissed: - wasInProgress = false; - break; - case AnimationStatus.forward: - case AnimationStatus.reverse: - wasInProgress = true; - break; - } - return wasInProgress && isInProgress; - } + static final Animatable _scaleDownTransition = Tween( + begin: 1.10, + end: 1.00, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable _scaleUpTransition = Tween( + begin: 0.85, + end: 1.00, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable _scrimOpacityTween = Tween( + begin: 0.0, + end: 0.60, + ).chain(CurveTween(curve: const Interval(0.2075, 0.4175))); @override Widget build(BuildContext context) { - final Animation _forwardScrimOpacityAnimation = widget.animation.drive( - _ZoomPageTransition._scrimOpacityTween - .chain(CurveTween(curve: const Interval(0.2075, 0.4175)))); + double opacity = 0; + // The transition's scrim opacity only increases on the forward transition. In the reverse + // transition, the opacity should always be 0.0. + // + // Therefore, we need to only apply the scrim opacity animation when the transition + // is running forwards. + // + // The reason that we check that the animation's status is not `completed` instead + // of checking that it is `forward` is that this allows the interrupted reversal of the + // forward transition to smoothly fade the scrim away. This prevents a disjointed + // removal of the scrim. + if (!reverse && animation.status != AnimationStatus.completed) { + opacity = _scrimOpacityTween.evaluate(animation); + } - final Animation _forwardEndScreenScaleTransition = widget.animation.drive( - Tween(begin: 0.85, end: 1.00) - .chain(_ZoomPageTransition._scaleCurveSequence)); + final Animation fadeTransition = reverse + ? kAlwaysCompleteAnimation + : _fadeInTransition.animate(animation); - final Animation _forwardStartScreenScaleTransition = widget.secondaryAnimation.drive( - Tween(begin: 1.00, end: 1.05) - .chain(_ZoomPageTransition._scaleCurveSequence)); - - final Animation _forwardEndScreenFadeTransition = widget.animation.drive( - Tween(begin: 0.0, end: 1.00) - .chain(CurveTween(curve: const Interval(0.125, 0.250)))); - - final Animation _reverseEndScreenScaleTransition = widget.secondaryAnimation.drive( - Tween(begin: 1.00, end: 1.10) - .chain(_ZoomPageTransition._flippedScaleCurveSequence)); - - final Animation _reverseStartScreenScaleTransition = widget.animation.drive( - Tween(begin: 0.9, end: 1.0) - .chain(_ZoomPageTransition._flippedScaleCurveSequence)); - - final Animation _reverseStartScreenFadeTransition = widget.animation.drive( - Tween(begin: 0.0, end: 1.00) - .chain(CurveTween(curve: const Interval(1 - 0.2075, 1 - 0.0825)))); + final Animation scaleTransition = (reverse + ? _scaleDownTransition + : _scaleUpTransition + ).animate(animation); return AnimatedBuilder( - animation: widget.animation, + animation: animation, builder: (BuildContext context, Widget child) { - if (widget.animation.status == AnimationStatus.forward || _transitionWasInterrupted) { - return Container( - color: Colors.black.withOpacity(_forwardScrimOpacityAnimation.value), - child: FadeTransition( - opacity: _forwardEndScreenFadeTransition, - child: ScaleTransition( - scale: _forwardEndScreenScaleTransition, - child: child, - ), - ), - ); - } else if (widget.animation.status == AnimationStatus.reverse) { - return ScaleTransition( - scale: _reverseStartScreenScaleTransition, - child: FadeTransition( - opacity: _reverseStartScreenFadeTransition, - child: child, - ), - ); - } - return child; + return Container( + color: Colors.black.withOpacity(opacity), + child: child, + ); }, - child: AnimatedBuilder( - animation: widget.secondaryAnimation, - builder: (BuildContext context, Widget child) { - if (widget.secondaryAnimation.status == AnimationStatus.forward || _transitionWasInterrupted) { - return ScaleTransition( - scale: _forwardStartScreenScaleTransition, - child: child, - ); - } else if (widget.secondaryAnimation.status == AnimationStatus.reverse) { - return ScaleTransition( - scale: _reverseEndScreenScaleTransition, - child: child, - ); - } - return child; - }, - child: widget.child, + child: FadeTransition( + opacity: fadeTransition, + child: ScaleTransition( + scale: scaleTransition, + child: child, + ), + ), + ); + } +} + +class _ZoomExitTransition extends StatelessWidget { + const _ZoomExitTransition({ + Key key, + @required this.animation, + this.reverse = false, + this.child, + }) : assert(animation != null), + assert(reverse != null), + super(key: key); + + final Animation animation; + final bool reverse; + final Widget child; + + static final Animatable _fadeOutTransition = Tween( + begin: 1.0, + end: 0.0, + ).chain(CurveTween(curve: const Interval(0.0825, 0.2075))); + + static final Animatable _scaleUpTransition = Tween( + begin: 1.00, + end: 1.05, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable _scaleDownTransition = Tween( + begin: 1.00, + end: 0.90, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + @override + Widget build(BuildContext context) { + final Animation fadeTransition = reverse + ? _fadeOutTransition.animate(animation) + : kAlwaysCompleteAnimation; + final Animation scaleTransition = (reverse + ? _scaleDownTransition + : _scaleUpTransition + ).animate(animation); + + return FadeTransition( + opacity: fadeTransition, + child: ScaleTransition( + scale: scaleTransition, + child: child, ), ); } diff --git a/packages/flutter/lib/src/widgets/dual_transition_builder.dart b/packages/flutter/lib/src/widgets/dual_transition_builder.dart new file mode 100644 index 0000000000..239aa111b2 --- /dev/null +++ b/packages/flutter/lib/src/widgets/dual_transition_builder.dart @@ -0,0 +1,203 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'basic.dart'; +import 'framework.dart'; + +/// Builder callback used by [DualTransitionBuilder]. +/// +/// The builder is expected to return a transition powered by the provided +/// `animation` and wrapping the provided `child`. +/// +/// The `animation` provided to the builder always runs forward from 0.0 to 1.0. +typedef AnimatedTransitionBuilder = Widget Function( + BuildContext context, + Animation animation, + Widget child, +); + +/// A transition builder that animates its [child] based on the +/// [AnimationStatus] of the provided [animation]. +/// +/// This widget can be used to specify different enter and exit transitions for +/// a [child]. +/// +/// While the [animation] runs forward, the [child] is animated according to +/// [forwardBuilder] and while the [animation] is running in reverse, it is +/// animated according to [reverseBuilder]. +/// +/// Using this builder allows the widget tree to maintain its shape by nesting +/// the enter and exit transitions. This ensures that no state information of +/// any descendant widget is lost when the transition starts or completes. +class DualTransitionBuilder extends StatefulWidget { + /// Creates a [DualTransitionBuilder]. + /// + /// The [animation], [forwardBuilder], and [reverseBuilder] arguments are + /// required and must not be null. + const DualTransitionBuilder({ + Key key, + @required this.animation, + @required this.forwardBuilder, + @required this.reverseBuilder, + this.child, + }) : assert(animation != null), + assert(forwardBuilder != null), + assert(reverseBuilder != null), + super(key: key); + + /// The animation that drives the [child]'s transition. + /// + /// When this animation runs forward, the [child] transitions as specified by + /// [forwardBuilder]. When it runs in reverse, the child transitions according + /// to [reverseBuilder]. + final Animation animation; + + /// A builder for the transition that makes [child] appear on screen. + /// + /// The [child] should be fully visible when the provided `animation` reaches + /// 1.0. + /// + /// The `animation` provided to this builder is running forward from 0.0 to + /// 1.0 when [animation] runs _forward_. When [animation] runs in reverse, + /// the given `animation` is set to [kAlwaysCompleteAnimation]. + /// + /// See also: + /// + /// * [reverseBuilder], which builds the transition for making the [child] + /// disappear from the screen. + final AnimatedTransitionBuilder forwardBuilder; + + /// A builder for a transition that makes [child] disappear from the screen. + /// + /// The [child] should be fully invisible when the provided `animation` + /// reaches 1.0. + /// + /// The `animation` provided to this builder is running forward from 0.0 to + /// 1.0 when [animation] runs in _reverse_. When [animation] runs forward, + /// the given `animation` is set to [kAlwaysDismissedAnimation]. + /// + /// See also: + /// + /// * [forwardBuilder], which builds the transition for making the [child] + /// appear on screen. + final AnimatedTransitionBuilder reverseBuilder; + + /// The widget below this [DualTransitionBuilder] in the tree. + /// + /// This child widget will be wrapped by the transitions built by + /// [forwardBuilder] and [reverseBuilder]. + final Widget child; + + @override + State createState() => _DualTransitionBuilderState(); +} + +class _DualTransitionBuilderState extends State { + AnimationStatus _effectiveAnimationStatus; + final ProxyAnimation _forwardAnimation = ProxyAnimation(); + final ProxyAnimation _reverseAnimation = ProxyAnimation(); + + @override + void initState() { + super.initState(); + _effectiveAnimationStatus = widget.animation.status; + widget.animation.addStatusListener(_animationListener); + _updateAnimations(); + } + + void _animationListener(AnimationStatus animationStatus) { + final AnimationStatus oldEffective = _effectiveAnimationStatus; + _effectiveAnimationStatus = _calculateEffectiveAnimationStatus( + lastEffective: _effectiveAnimationStatus, + current: animationStatus, + ); + if (oldEffective != _effectiveAnimationStatus) { + _updateAnimations(); + } + } + + @override + void didUpdateWidget(DualTransitionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.animation != widget.animation) { + oldWidget.animation.removeStatusListener(_animationListener); + widget.animation.addStatusListener(_animationListener); + _animationListener(widget.animation.status); + } + } + + // 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 + } + + void _updateAnimations() { + switch (_effectiveAnimationStatus) { + case AnimationStatus.dismissed: + case AnimationStatus.forward: + _forwardAnimation.parent = widget.animation; + _reverseAnimation.parent = kAlwaysDismissedAnimation; + break; + case AnimationStatus.reverse: + case AnimationStatus.completed: + _forwardAnimation.parent = kAlwaysCompleteAnimation; + _reverseAnimation.parent = ReverseAnimation(widget.animation); + break; + } + } + + @override + void dispose() { + widget.animation.removeStatusListener(_animationListener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.forwardBuilder( + context, + _forwardAnimation, + widget.reverseBuilder( + context, + _reverseAnimation, + widget.child, + ), + ); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index d8cc265101..1805f6ed96 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -37,6 +37,7 @@ export 'src/widgets/dismissible.dart'; export 'src/widgets/disposable_build_context.dart'; export 'src/widgets/drag_target.dart'; export 'src/widgets/draggable_scrollable_sheet.dart'; +export 'src/widgets/dual_transition_builder.dart'; export 'src/widgets/editable_text.dart'; export 'src/widgets/fade_in_image.dart'; export 'src/widgets/focus_manager.dart'; diff --git a/packages/flutter/test/material/page_transitions_theme_test.dart b/packages/flutter/test/material/page_transitions_theme_test.dart index e823432c5f..ba1efc32c6 100644 --- a/packages/flutter/test/material/page_transitions_theme_test.dart +++ b/packages/flutter/test/material/page_transitions_theme_test.dart @@ -161,4 +161,50 @@ void main() { expect(find.text('page b'), findsOneWidget); expect(findZoomPageTransition(), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('_ZoomPageTransition only cause child widget built once', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/58345 + + int builtCount = 0; + + final Map routes = { + '/': (BuildContext context) => Material( + child: FlatButton( + child: const Text('push'), + onPressed: () { Navigator.of(context).pushNamed('/b'); }, + ), + ), + '/b': (BuildContext context) => StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + builtCount++; // Increase [builtCount] each time the widget build + return FlatButton( + child: const Text('pop'), + onPressed: () { Navigator.pop(context); }, + ); + }, + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), // creates a _ZoomPageTransition + }, + ), + ), + routes: routes, + ), + ); + + // No matter push or pop was called, the child widget should built only once. + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(builtCount, 1); + + await tester.tap(find.text('pop')); + await tester.pumpAndSettle(); + expect(builtCount, 1); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); } diff --git a/packages/flutter/test/widgets/dual_transition_builder_test.dart b/packages/flutter/test/widgets/dual_transition_builder_test.dart new file mode 100644 index 0000000000..398173cc80 --- /dev/null +++ b/packages/flutter/test/widgets/dual_transition_builder_test.dart @@ -0,0 +1,302 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:flutter/src/widgets/dual_transition_builder.dart'; + +void main() { + testWidgets('runs animations', (WidgetTester tester) async { + final AnimationController controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 300), + ); + + await tester.pumpWidget(Center( + child: DualTransitionBuilder( + animation: controller, + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0).animate(animation), + child: child, + ); + }, + child: Container( + color: Colors.green, + height: 100, + width: 100, + ), + ), + )); + expect(_getScale(tester), 0.0); + expect(_getOpacity(tester), 1.0); + + controller.forward(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 0.5); + expect(_getOpacity(tester), 1.0); + + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 1.0); + + await tester.pumpAndSettle(); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 1.0); + + controller.reverse(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.5); + + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.0); + + await tester.pumpAndSettle(); + expect(_getScale(tester), 0.0); + expect(_getOpacity(tester), 1.0); + }); + + testWidgets('keeps state', (WidgetTester tester) async { + final AnimationController controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 300), + ); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: DualTransitionBuilder( + animation: controller, + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0).animate(animation), + child: child, + ); + }, + child: const _StatefulTestWidget(name: 'Foo'), + ), + ), + )); + final State state = + tester.state(find.byType(_StatefulTestWidget)); + expect(state, isNotNull); + + controller.forward(); + await tester.pump(); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + await tester.pump(const Duration(milliseconds: 150)); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + + await tester.pump(const Duration(milliseconds: 150)); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + + await tester.pumpAndSettle(); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + + controller.reverse(); + await tester.pump(); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + await tester.pump(const Duration(milliseconds: 150)); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + + await tester.pump(const Duration(milliseconds: 150)); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + + await tester.pumpAndSettle(); + expect(state, same(tester.state(find.byType(_StatefulTestWidget)))); + }); + + testWidgets('does not jump when interrupted - forward', + (WidgetTester tester) async { + final AnimationController controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 300), + ); + await tester.pumpWidget(Center( + child: DualTransitionBuilder( + animation: controller, + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0).animate(animation), + child: child, + ); + }, + child: Container( + color: Colors.green, + height: 100, + width: 100, + ), + ), + )); + expect(_getScale(tester), 0.0); + expect(_getOpacity(tester), 1.0); + + controller.forward(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 0.5); + expect(_getOpacity(tester), 1.0); + + controller.reverse(); + expect(_getScale(tester), 0.5); + expect(_getOpacity(tester), 1.0); + await tester.pump(); + expect(_getScale(tester), 0.5); + expect(_getOpacity(tester), 1.0); + + await tester.pump(const Duration(milliseconds: 75)); + expect(_getScale(tester), 0.25); + expect(_getOpacity(tester), 1.0); + + await tester.pump(const Duration(milliseconds: 75)); + expect(_getScale(tester), 0.0); + expect(_getOpacity(tester), 1.0); + + await tester.pumpAndSettle(); + expect(_getScale(tester), 0.0); + expect(_getOpacity(tester), 1.0); + }); + + testWidgets('does not jump when interrupted - reverse', + (WidgetTester tester) async { + final AnimationController controller = AnimationController( + value: 1.0, + vsync: const TestVSync(), + duration: const Duration(milliseconds: 300), + ); + await tester.pumpWidget(Center( + child: DualTransitionBuilder( + animation: controller, + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget child, + ) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0).animate(animation), + child: child, + ); + }, + child: Container( + color: Colors.green, + height: 100, + width: 100, + ), + ), + )); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 1.0); + + controller.reverse(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.5); + + controller.forward(); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.5); + await tester.pump(); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.5); + + await tester.pump(const Duration(milliseconds: 75)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 0.75); + + await tester.pump(const Duration(milliseconds: 75)); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 1.0); + + await tester.pumpAndSettle(); + expect(_getScale(tester), 1.0); + expect(_getOpacity(tester), 1.0); + }); +} + +double _getScale(WidgetTester tester) { + final ScaleTransition scale = tester.widget(find.byType(ScaleTransition)); + return scale.scale.value; +} + +double _getOpacity(WidgetTester tester) { + final FadeTransition scale = tester.widget(find.byType(FadeTransition)); + return scale.opacity.value; +} + +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); + } +}