From d4a016443b3a82649dfd5d924adb98a01053e098 Mon Sep 17 00:00:00 2001 From: Robin Jespersen Date: Wed, 18 Sep 2019 23:56:18 +0200 Subject: [PATCH] Adding onEnd callback to implicit animated widgets (#38979) --- packages/flutter/lib/src/material/theme.dart | 3 +- .../lib/src/widgets/implicit_animations.dart | 45 ++- .../src/widgets/tween_animation_builder.dart | 26 +- .../widgets/implicit_animations_test.dart | 370 ++++++++++++++++++ 4 files changed, 410 insertions(+), 34 deletions(-) diff --git a/packages/flutter/lib/src/material/theme.dart b/packages/flutter/lib/src/material/theme.dart index 1e3a559650..cfe015f42a 100644 --- a/packages/flutter/lib/src/material/theme.dart +++ b/packages/flutter/lib/src/material/theme.dart @@ -227,10 +227,11 @@ class AnimatedTheme extends ImplicitlyAnimatedWidget { this.isMaterialAppTheme = false, Curve curve = Curves.linear, Duration duration = kThemeAnimationDuration, + VoidCallback onEnd, @required this.child, }) : assert(child != null), assert(data != null), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// Specifies the color and typography values for descendant widgets. final ThemeData data; diff --git a/packages/flutter/lib/src/widgets/implicit_animations.dart b/packages/flutter/lib/src/widgets/implicit_animations.dart index 98c4d076f9..099eef57c0 100644 --- a/packages/flutter/lib/src/widgets/implicit_animations.dart +++ b/packages/flutter/lib/src/widgets/implicit_animations.dart @@ -276,6 +276,7 @@ abstract class ImplicitlyAnimatedWidget extends StatefulWidget { Key key, this.curve = Curves.linear, @required this.duration, + this.onEnd, }) : assert(curve != null), assert(duration != null), super(key: key); @@ -286,6 +287,12 @@ abstract class ImplicitlyAnimatedWidget extends StatefulWidget { /// The duration over which to animate the parameters of this container. final Duration duration; + /// Called every time an animation completes. + /// + /// This can be useful to trigger additional actions (e.g. another animation) + /// at the end of the current animation. + final VoidCallback onEnd; + @override ImplicitlyAnimatedWidgetState createState(); @@ -356,6 +363,17 @@ abstract class ImplicitlyAnimatedWidgetState debugLabel: kDebugMode ? '${widget.toStringShort()}' : null, vsync: this, ); + _controller.addStatusListener((AnimationStatus status) { + switch (status) { + case AnimationStatus.completed: + if (widget.onEnd != null) + widget.onEnd(); + break; + case AnimationStatus.dismissed: + case AnimationStatus.forward: + case AnimationStatus.reverse: + } + }); _updateCurve(); _constructTweens(); didUpdateTweens(); @@ -624,6 +642,7 @@ class AnimatedContainer extends ImplicitlyAnimatedWidget { this.child, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(margin == null || margin.isNonNegative), assert(padding == null || padding.isNonNegative), assert(decoration == null || decoration.debugAssertIsValid()), @@ -638,7 +657,7 @@ class AnimatedContainer extends ImplicitlyAnimatedWidget { ? constraints?.tighten(width: width, height: height) ?? BoxConstraints.tightFor(width: width, height: height) : constraints, - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The [child] contained by the container. /// @@ -780,9 +799,10 @@ class AnimatedPadding extends ImplicitlyAnimatedWidget { this.child, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(padding != null), assert(padding.isNonNegative), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The amount of space by which to inset the child. final EdgeInsetsGeometry padding; @@ -859,8 +879,9 @@ class AnimatedAlign extends ImplicitlyAnimatedWidget { this.child, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(alignment != null), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// How to align the child. /// @@ -967,9 +988,10 @@ class AnimatedPositioned extends ImplicitlyAnimatedWidget { this.height, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(left == null || right == null || width == null), assert(top == null || bottom == null || height == null), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// Creates a widget that animates the rectangle it occupies implicitly. /// @@ -980,13 +1002,14 @@ class AnimatedPositioned extends ImplicitlyAnimatedWidget { Rect rect, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : left = rect.left, top = rect.top, width = rect.width, height = rect.height, right = null, bottom = null, - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The widget below this widget in the tree. /// @@ -1118,9 +1141,10 @@ class AnimatedPositionedDirectional extends ImplicitlyAnimatedWidget { this.height, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(start == null || end == null || width == null), assert(top == null || bottom == null || height == null), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The widget below this widget in the tree. /// @@ -1274,9 +1298,10 @@ class AnimatedOpacity extends ImplicitlyAnimatedWidget { @required this.opacity, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, this.alwaysIncludeSemantics = false, }) : assert(opacity != null && opacity >= 0.0 && opacity <= 1.0), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The widget below this widget in the tree. /// @@ -1369,12 +1394,13 @@ class AnimatedDefaultTextStyle extends ImplicitlyAnimatedWidget { this.maxLines, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(style != null), assert(child != null), assert(softWrap != null), assert(overflow != null), assert(maxLines == null || maxLines > 0), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The widget below this widget in the tree. /// @@ -1484,6 +1510,7 @@ class AnimatedPhysicalModel extends ImplicitlyAnimatedWidget { this.animateShadowColor = true, Curve curve = Curves.linear, @required Duration duration, + VoidCallback onEnd, }) : assert(child != null), assert(shape != null), assert(clipBehavior != null), @@ -1493,7 +1520,7 @@ class AnimatedPhysicalModel extends ImplicitlyAnimatedWidget { assert(shadowColor != null), assert(animateColor != null), assert(animateShadowColor != null), - super(key: key, curve: curve, duration: duration); + super(key: key, curve: curve, duration: duration, onEnd: onEnd); /// The widget below this widget in the tree. /// diff --git a/packages/flutter/lib/src/widgets/tween_animation_builder.dart b/packages/flutter/lib/src/widgets/tween_animation_builder.dart index fb60e459bc..f053ea3d49 100644 --- a/packages/flutter/lib/src/widgets/tween_animation_builder.dart +++ b/packages/flutter/lib/src/widgets/tween_animation_builder.dart @@ -127,12 +127,12 @@ class TweenAnimationBuilder extends ImplicitlyAnimatedWidget { @required Duration duration, Curve curve = Curves.linear, @required this.builder, - this.onEnd, + VoidCallback onEnd, this.child, }) : assert(tween != null), assert(curve != null), assert(builder != null), - super(key: key, duration: duration, curve: curve); + super(key: key, duration: duration, curve: curve, onEnd: onEnd); /// Defines the target value for the animation. /// @@ -186,12 +186,6 @@ class TweenAnimationBuilder extends ImplicitlyAnimatedWidget { /// performance significantly in some cases and is therefore a good practice. final Widget child; - /// Called every time an animation completes. - /// - /// This can be useful to trigger additional actions (e.g. another animation) - /// at the end of the current animation. - final VoidCallback onEnd; - @override ImplicitlyAnimatedWidgetState createState() { return _TweenAnimationBuilderState(); @@ -206,27 +200,11 @@ class _TweenAnimationBuilderState extends AnimatedWidgetBaseState visitor) { assert( diff --git a/packages/flutter/test/widgets/implicit_animations_test.dart b/packages/flutter/test/widgets/implicit_animations_test.dart index 9759fb5ce0..45e66e04b2 100644 --- a/packages/flutter/test/widgets/implicit_animations_test.dart +++ b/packages/flutter/test/widgets/implicit_animations_test.dart @@ -2,10 +2,29 @@ // 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'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; +class MockOnEndFunction implements Function { + int called = 0; + + void call() { + called++; + } +} + +const Duration animationDuration = Duration(milliseconds:1000); +const Duration additionalDelay = Duration(milliseconds:1); + void main() { + MockOnEndFunction mockOnEndFunction; + const Key switchKey = Key('switchKey'); + + setUp(() { + mockOnEndFunction = MockOnEndFunction(); + }); + testWidgets('BoxConstraintsTween control test', (WidgetTester tester) async { final BoxConstraintsTween tween = BoxConstraintsTween( begin: BoxConstraints.tight(const Size(20.0, 50.0)), @@ -47,4 +66,355 @@ void main() { final Matrix4 result = tween.lerp(0.25); expect(result, equals(Matrix4.translationValues(11.0, 21.0, 31.0))); }); + + testWidgets('AnimatedContainer onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedContainerWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedPadding onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedPaddingWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedAlign onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedAlignWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedPositioned onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedPositionedWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedPositionedDirectional onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedPositionedDirectionalWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedOpacity onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedOpacityWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedDefaultTextStyle onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedDefaultTextStyleWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedPhysicalModel onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedPhysicalModelWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('TweenAnimationBuilder onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestTweenAnimationBuilderWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); + + testWidgets('AnimatedTheme onEnd callback test', (WidgetTester tester) async { + await tester.pumpWidget(wrap( + child: TestAnimatedWidget(callback: mockOnEndFunction, switchKey: switchKey, state: _TestAnimatedThemeWidgetState(),) + )); + + final Finder widgetFinder = find.byKey(switchKey); + + await tester.tap(widgetFinder); + + await tester.pump(); + expect(mockOnEndFunction.called, 0); + await tester.pump(animationDuration); + expect(mockOnEndFunction.called, 0); + await tester.pump(additionalDelay); + expect(mockOnEndFunction.called, 1); + }); +} + +Widget wrap({Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center(child: child), + ), + ); +} + +class TestAnimatedWidget extends StatefulWidget { + const TestAnimatedWidget({this.callback, this.switchKey, this.state}); + @required + final VoidCallback callback; + @required + final Key switchKey; + @required + final State state; + + @override + State createState() => state; +} + +abstract class _TestAnimatedWidgetState extends State { + bool toggle = false; + final Widget child = const Placeholder(); + final Duration duration = animationDuration; + + void onChanged(bool v) { + setState(() { + toggle = v; + }); + } + + Widget getAnimatedWidget(); + + @override + Widget build(BuildContext context) { + final Widget animatedWidget = getAnimatedWidget(); + + return Stack( + children: [ + animatedWidget, + Switch(key: widget.switchKey, value: toggle, onChanged: onChanged), + ], + ); + } +} + + +class _TestAnimatedContainerWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedContainer( + child: child, + duration: duration, + onEnd: widget.callback, + width: toggle ? 10 : 20, + ); + } +} + +class _TestAnimatedPaddingWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedPadding( + child: child, + duration: duration, + onEnd: widget.callback, + padding: + toggle ? const EdgeInsets.all(8.0) : const EdgeInsets.all(16.0), + ); + } +} + +class _TestAnimatedAlignWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedAlign( + child: child, + duration: duration, + onEnd: widget.callback, + alignment: toggle ? Alignment.topLeft : Alignment.bottomRight, + ); + } +} + +class _TestAnimatedPositionedWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedPositioned( + child: child, + duration: duration, + onEnd: widget.callback, + left: toggle ? 10 : 20, + ); + } +} + +class _TestAnimatedPositionedDirectionalWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedPositionedDirectional( + child: child, + duration: duration, + onEnd: widget.callback, + start: toggle ? 10 : 20, + ); + } +} + +class _TestAnimatedOpacityWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedOpacity( + child: child, + duration: duration, + onEnd: widget.callback, + opacity: toggle ? 0.1 : 0.9, + ); + } +} + +class _TestAnimatedDefaultTextStyleWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedDefaultTextStyle( + child: child, + duration: duration, + onEnd: widget.callback, + style: toggle + ? const TextStyle(fontStyle: FontStyle.italic) + : const TextStyle(fontStyle: FontStyle.normal)); + } +} + +class _TestAnimatedPhysicalModelWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedPhysicalModel( + child: child, + duration: duration, + onEnd: widget.callback, + color: toggle ? Colors.red : Colors.green, + elevation: 0, + shadowColor: Colors.blue, + shape: BoxShape.rectangle, + ); + } +} + +class _TestTweenAnimationBuilderWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return TweenAnimationBuilder( + child: child, + tween: Tween(begin: 1, end: 2), + duration: duration, + onEnd: widget.callback, + builder: (BuildContext context, double size, Widget child) { + return Container( + child: child, + width: size, + height: size, + ); + } + ); + } +} + +class _TestAnimatedThemeWidgetState extends _TestAnimatedWidgetState { + @override + Widget getAnimatedWidget() { + return AnimatedTheme( + child: child, + data: toggle ? ThemeData.dark() : ThemeData.light(), + duration: duration, + onEnd: widget.callback, + ); + } }