diff --git a/packages/flutter/lib/src/widgets/implicit_animations.dart b/packages/flutter/lib/src/widgets/implicit_animations.dart index 9b2e2f3322..0667368604 100644 --- a/packages/flutter/lib/src/widgets/implicit_animations.dart +++ b/packages/flutter/lib/src/widgets/implicit_animations.dart @@ -247,6 +247,8 @@ class TextStyleTween extends Tween { /// usually named `AnimatedFoo`, where `Foo` is the name of the non-animated /// version of that widget. Commonly used implicitly animated widgets include: /// +/// * [TweenAnimationBuilder], which animates any property expressed by +/// a [Tween] to a specified target value. /// * [AnimatedAlign], which is an implicitly animated version of [Align]. /// * [AnimatedContainer], which is an implicitly animated version of /// [Container]. diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index eb1910b3d5..3ebdbabae9 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -917,6 +917,11 @@ class DefaultTextStyleTransition extends AnimatedWidget { /// } /// ``` /// {@end-tool} +/// +/// See also: +/// +/// * [TweenAnimationBuilder], which animates a property to a target value +/// without requiring manual management of an [AnimationController]. class AnimatedBuilder extends AnimatedWidget { /// Creates an animated builder. /// diff --git a/packages/flutter/lib/src/widgets/tween_animation_builder.dart b/packages/flutter/lib/src/widgets/tween_animation_builder.dart new file mode 100644 index 0000000000..92b87bbcf1 --- /dev/null +++ b/packages/flutter/lib/src/widgets/tween_animation_builder.dart @@ -0,0 +1,249 @@ +// Copyright 2019 The Chromium 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/animation.dart'; + +import 'framework.dart'; +import 'implicit_animations.dart'; +import 'value_listenable_builder.dart'; + +/// [Widget] builder that animates a property of a [Widget] to a target value +/// whenever the target value changes. +/// +/// The type of the animated property ([Color], [Rect], [double], etc.) is +/// defined via the type of the provided [tween] (e.g. [ColorTween], +/// [RectTween], [Tween], etc.). +/// +/// The [tween] also defines the target value for the animation: When the widget +/// first builds, it animates from [Tween.begin] to [Tween.end]. A new animation +/// can be triggered anytime by providing a new [tween] with a new [Tween.end] +/// value. The new animation runs from the current animation value (which may be +/// [Tween.end] of the old [tween], if that animation completed) to [Tween.end] +/// of the new [tween]. +/// +/// The animation is further customized by providing a [curve] and [duration]. +/// +/// The current value of the animation along with the [child] is passed to +/// the [builder] callback, which is expected to build a [Widget] based on the +/// current animation value. The [builder] is called throughout the animation +/// for every animation value until [Tween.end] is reached. +/// +/// A provided [onEnd] callback is called whenever an animation completes. +/// Registering an [onEnd] callback my be useful to trigger an action (like +/// another animation) at the end of the current animation. +/// +/// ## Performance optimizations +/// +/// If your [builder] function contains a subtree that does not depend on the +/// animation, it's more efficient to build that subtree once instead of +/// rebuilding it on every animation tick. +/// +/// If you pass the pre-built subtree as the [child] parameter, the +/// AnimatedBuilder will pass it back to your builder function so that you +/// can incorporate it into your build. +/// +/// Using this pre-built child is entirely optional, but can improve +/// performance significantly in some cases and is therefore a good practice. +/// +/// ## Ownership of the [Tween] +/// +/// The [TweenAnimationBuilder] takes full ownership of the provided [tween] +/// instance and it will mutate it. Once a [Tween] has been passed to a +/// [TweenAnimationBuilder], its properties should not be accessed or changed +/// anymore to avoid interference with the [TweenAnimationBuilder]. +/// +/// It is good practice to never store a [Tween] provided to a +/// [TweenAnimationBuilder] in an instance variable to avoid accidental +/// modifications of the [Tween]. +/// +/// ## Example Code +/// +/// {@tool snippet --template=stateful_widget_scaffold} +/// This example shows an [IconButton] that "zooms" in when the widget first +/// builds (its size smoothly increases from 0 to 24) and whenever the button +/// is pressed, it smoothly changes its size to the new target value of either +/// 48 or 24. +/// +/// ```dart +/// double targetValue = 24.0; +/// +/// @override +/// Widget build(BuildContext context) { +/// return Center( +/// child: TweenAnimationBuilder( +/// tween: Tween(begin: 0, end: targetValue), +/// duration: Duration(seconds: 1), +/// builder: (BuildContext context, double size, Widget child) { +/// return IconButton( +/// iconSize: size, +/// color: Colors.blue, +/// icon: child, +/// onPressed: () { +/// setState(() { +/// targetValue = targetValue == 24.0 ? 48.0 : 24.0; +/// }); +/// }, +/// ); +/// }, +/// child: Icon(Icons.aspect_ratio), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// ## Relationship to [ImplicitlyAnimatedWidget]s and [AnimatedWidget]s +/// +/// The [ImplicitlyAnimatedWidget] has many subclasses that provide animated +/// versions of regular widgets. These subclasses (like [AnimatedOpacity], +/// [AnimatedContainer], [AnimatedSize], etc.) animate changes in their +/// properties smoothly and they are easier to use than this general-purpose +/// builder. However, [TweenAnimationBuilder] (which itself is a subclass of +/// [ImplicitlyAnimatedWidget]) is handy for animating any widget property to a +/// given target value even when the framework (or third-party widget library) +/// doesn't ship with an animated version of that widget. +/// +/// Those [ImplicitlyAnimatedWidget]s (including this [TweenAnimationBuilder]) +/// all manage an internal [AnimationController] to drive the animation. If you +/// want more control over the animation than just setting a target value, +/// [duration], and [curve], have a look at (subclasses of) [AnimatedWidget]s. +/// For those, you have to manually manage an [AnimationController] giving you +/// full control over the animation. An example of an [AnimatedWidget] is the +/// [AnimatedBuilder], which can be used similarly to this +/// [TweenAnimationBuilder], but unlike the latter it is powered by a +/// developer-managed [AnimationController]. +class TweenAnimationBuilder extends ImplicitlyAnimatedWidget { + /// Creates a [TweenAnimationBuilder]. + /// + /// The properties [tween], [duration], and [builder] are required. The values + /// for [tween], [curve], and [builder] must not be null. + /// + /// The [TweenAnimationBuilder] takes full ownership of the provided [tween] + /// instance and mutates it. Once a [Tween] has been passed to a + /// [TweenAnimationBuilder], its properties should not be accessed or changed + /// anymore to avoid interference with the [TweenAnimationBuilder]. + const TweenAnimationBuilder({ + Key key, + @required this.tween, + @required Duration duration, + Curve curve = Curves.linear, + @required this.builder, + this.onEnd, + this.child, + }) : assert(tween != null), + assert(curve != null), + assert(builder != null), + super(key: key, duration: duration, curve: curve); + + /// Defines the target value for the animation. + /// + /// When the widget first builds, the animation runs from [Tween.begin] to + /// [Tween.end], if [Tween.begin] is non-null. A new animation can be + /// triggered at anytime by providing a new [Tween] with a new [Tween.end] + /// value. The new animation runs from the current animation value (which may + /// be [Tween.end] of the old [tween], if that animation completed) to + /// [Tween.end] of the new [tween]. The [Tween.begin] value is ignored except + /// for the initial animation that is triggered when the widget builds for the + /// first time. + /// + /// Any (subclass of) [Tween] is accepted as an argument. For example, to + /// animate the height or width of a [Widget], use a [Tween], or + /// check out the [ColorTween] to animate the color property of a [Widget]. + /// + /// Any [Tween] provided must have a non-null [Tween.end] value. + /// + /// ## Ownership + /// + /// The [TweenAnimationBuilder] takes full ownership of the provided [Tween] + /// and it will mutate the [Tween]. Once a [Tween] instance has been passed + /// to [TweenAnimationBuilder] its properties should not be accessed or + /// changed anymore to avoid any interference with the + /// [TweenAnimationBuilder]. If you need to change the [Tween], create a + /// **new instance** with the new values. + /// + /// It is good practice to never store a [Tween] provided to a + /// [TweenAnimationBuilder] in an instance variable to avoid accidental + /// modifications of the [Tween]. + final Tween tween; + + /// Called every time the animation value changes. + /// + /// The current animation value is passed to the builder along with the + /// [child]. The builder should build a [Widget] based on the current + /// animation value and incorporate the [child] into it, if it is non-null. + final ValueWidgetBuilder builder; + + /// The child widget to pass to the builder. + /// + /// If a builder callback's return value contains a subtree that does not + /// depend on the animation, it's more efficient to build that subtree once + /// instead of rebuilding it on every animation tick. + /// + /// If the pre-built subtree is passed as the child parameter, the + /// [TweenAnimationBuilder] will pass it back to the [builder] function so + /// that it can be incorporated into the build. + /// + /// Using this pre-built child is entirely optional, but can improve + /// 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(); + } +} + +class _TweenAnimationBuilderState extends AnimatedWidgetBaseState> { + Tween _currentTween; + + @override + void initState() { + _currentTween = widget.tween; + _currentTween.begin ??= _currentTween.end; + super.initState(); + // The statusListener is removed when the superclass disposes the controller. + controller.addStatusListener(_onAnimationStatusChanged); + if (_currentTween.begin != _currentTween.end) { + controller.forward(); + } + } + + void _onAnimationStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + case AnimationStatus.completed: + if (widget.onEnd != null) { + widget.onEnd(); + } + break; + } + } + + @override + void forEachTween(TweenVisitor visitor) { + assert( + widget.tween.end != null, + 'Tween provided to TweenAnimationBuilder must have non-null Tween.end value.', + ); + _currentTween = visitor(_currentTween, widget.tween.end, (dynamic value) { + // Constructor will never be called because null is never provided as current tween. + assert(false); + return null; + }); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _currentTween.evaluate(animation), widget.child); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index ac16980dc5..483bc82121 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -106,6 +106,7 @@ export 'src/widgets/texture.dart'; export 'src/widgets/ticker_provider.dart'; export 'src/widgets/title.dart'; export 'src/widgets/transitions.dart'; +export 'src/widgets/tween_animation_builder.dart'; export 'src/widgets/unique_widget.dart'; export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/viewport.dart'; diff --git a/packages/flutter/test/widgets/tween_animation_builder_test.dart b/packages/flutter/test/widgets/tween_animation_builder_test.dart new file mode 100644 index 0000000000..55212f4f64 --- /dev/null +++ b/packages/flutter/test/widgets/tween_animation_builder_test.dart @@ -0,0 +1,390 @@ +// Copyright 2019 The Chromium 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_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + testWidgets('Animates forward when built', (WidgetTester tester) async { + final List values = []; + int endCount = 0; + await tester.pumpWidget( + TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: IntTween(begin: 10, end: 110), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + onEnd: () { + endCount++; + }, + ), + ); + expect(endCount, 0); + expect(values, [10]); + + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [10, 60]); + + await tester.pump(const Duration(milliseconds: 501)); + expect(endCount, 1); + expect(values, [10, 60, 110]); + + await tester.pump(const Duration(milliseconds: 500)); + expect(endCount, 1); + expect(values, [10, 60, 110]); + }); + + testWidgets('No initial animation when begin=null', (WidgetTester tester) async { + final List values = []; + int endCount = 0; + await tester.pumpWidget( + TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: IntTween(end: 100), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + onEnd: () { + endCount++; + }, + ), + ); + expect(endCount, 0); + expect(values, [100]); + await tester.pump(const Duration(seconds: 2)); + expect(endCount, 0); + expect(values, [100]); + }); + + + testWidgets('No initial animation when begin=end', (WidgetTester tester) async { + final List values = []; + int endCount = 0; + await tester.pumpWidget( + TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: IntTween(begin: 100, end: 100), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + onEnd: () { + endCount++; + }, + ), + ); + expect(endCount, 0); + expect(values, [100]); + await tester.pump(const Duration(seconds: 2)); + expect(endCount, 0); + expect(values, [100]); + }); + + testWidgets('Replace tween animates new tween', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: tween, + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 0, end: 100))); + expect(values, [0]); + await tester.pump(const Duration(seconds: 2)); // finish first animation. + expect(values, [0, 100]); + + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 100, end: 200))); + expect(values, [0, 100, 100]); + + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 100, 100, 150]); + + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 100, 100, 150, 200]); + }); + + testWidgets('Curve is respected', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween, Curve curve}) { + return TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: tween, + curve: curve, + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 0, end: 100), curve: Curves.easeInExpo)); + expect(values, [0]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values.last, lessThan(50)); + expect(values.last, greaterThan(0)); + + await tester.pump(const Duration(seconds: 2)); // finish animation. + + values.clear(); + // Update curve (and tween to re-trigger animation). + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 100, end: 200), curve: Curves.linear)); + expect(values, [100]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [100, 150]); + }); + + testWidgets('Duration is respected', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween, Duration duration}) { + return TweenAnimationBuilder( + tween: tween, + duration: duration, + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 0, end: 100), duration: const Duration(seconds: 1))); + expect(values, [0]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50]); + + await tester.pump(const Duration(seconds: 2)); // finish animation. + + values.clear(); + // Update duration (and tween to re-trigger animation). + await tester.pumpWidget(buildWidget(tween: IntTween(begin: 100, end: 200), duration: const Duration(seconds: 2))); + expect(values, [100]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [100, 125]); + }); + + testWidgets('Child is integrated into tree', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TweenAnimationBuilder( + tween: IntTween(begin: 0, end: 100), + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + return child; + }, + child: const Text('Hello World'), + ), + ), + ); + + expect(find.text('Hello World'), findsOneWidget); + }); + + group('Change tween gapless while', () { + testWidgets('running forward', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + tween: tween, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 0, end: 100), + )); + expect(values, [0]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50]); + + // Change tween + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 200, end: 300), + )); + expect(values, [0, 50, 50]); // gapless: animation continues where it left off. + + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50, 50, 175]); // 175 = halfway between 50 and new target 300. + + // Run animation to end + await tester.pump(const Duration(seconds: 2)); + expect(values, [0, 50, 50, 175, 300]); + values.clear(); + }); + + testWidgets('running forward and then reverse with same tween instance', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + tween: tween, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + final IntTween tween1 = IntTween(begin: 0, end: 100); + final IntTween tween2 = IntTween(begin: 200, end: 300); + + await tester.pumpWidget(buildWidget( + tween: tween1, + )); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpWidget(buildWidget( + tween: tween2, + )); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(seconds: 2)); + expect(values, [0, 50, 50, 175, 300]); + values.clear(); + }); + }); + + testWidgets('Changing tween while gapless tween change is in progress', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + tween: tween, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + final IntTween tween1 = IntTween(begin: 0, end: 100); + final IntTween tween2 = IntTween(begin: 200, end: 300); + final IntTween tween3 = IntTween(begin: 400, end: 501); + + await tester.pumpWidget(buildWidget( + tween: tween1, + )); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50]); + values.clear(); + + // Change tween + await tester.pumpWidget(buildWidget( + tween: tween2, + )); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [50, 175]); + values.clear(); + + await tester.pumpWidget(buildWidget( + tween: tween3, + )); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [175, 338, 501]); + }); + + testWidgets('Changing curve while no animation is running does not trigger animation', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({Curve curve}) { + return TweenAnimationBuilder( + tween: IntTween(begin: 0, end: 100), + curve: curve, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget( + curve: Curves.linear, + )); + await tester.pump(const Duration(seconds: 2)); + expect(values, [0, 100]); + values.clear(); + + await tester.pumpWidget(buildWidget( + curve: Curves.easeInExpo, + )); + expect(values, [100]); + await tester.pump(const Duration(seconds: 2)); + expect(values, [100]); + }); + + testWidgets('Setting same tween and direction does not trigger animation', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + tween: tween, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 0, end: 100), + )); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50, 100]); + values.clear(); + + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 0, end: 100), + )); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, everyElement(100)); + }); + + testWidgets('Setting same tween and direction while gapless animation is in progress works', (WidgetTester tester) async { + final List values = []; + Widget buildWidget({IntTween tween}) { + return TweenAnimationBuilder( + tween: tween, + duration: const Duration(seconds: 1), + builder: (BuildContext context, int i, Widget child) { + values.add(i); + return const Placeholder(); + }, + ); + } + + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 0, end: 100), + )); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50]); + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 200, end: 300), + )); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50, 50, 175]); + + await tester.pumpWidget(buildWidget( + tween: IntTween(begin: 200, end: 300), + )); + expect(values, [0, 50, 50, 175, 175]); + await tester.pump(const Duration(milliseconds: 500)); + expect(values, [0, 50, 50, 175, 175, 300]); + + values.clear(); + await tester.pump(const Duration(seconds: 2)); + expect(values, everyElement(300)); + }); +}