63.1 Basic Structure of Animation
In Flutter, animations can be implemented in various ways. Below, we will demonstrate the differences between different animation implementations in Flutter using an example where an image gradually enlarges.
1. Basic Version
Let's first demonstrate the most basic way to implement animation:
class ScaleAnimationRoute extends StatefulWidget { const ScaleAnimationRoute({Key? key}) : super(key: key); @override _ScaleAnimationRouteState createState() => _ScaleAnimationRouteState(); } // TickerProvider must be inherited, and if multiple AnimationControllers are used, TickerProviderStateMixin should be used. class _ScaleAnimationRouteState extends State<ScaleAnimationRoute> with SingleTickerProviderStateMixin { late Animation<double> animation; late AnimationController controller; @override initState() { super.initState(); controller = AnimationController( duration: const Duration(seconds: 2), vsync: this, ); // Uniform speed // The width and height of the image grow from 0 to 300 animation = Tween(begin: 0.0, end: 300.0).animate(controller) ..addListener(() { setState(() => {}); }); // Start the animation (play forward) controller.forward(); } @override Widget build(BuildContext context) { return Center( child: Image.asset( "imgs/avatar.png", width: animation.value, height: animation.value, ), ); } @override dispose() { // Release animation resources when the route is destroyed controller.dispose(); super.dispose(); } }
In the code above, the addListener()
function calls setState()
, so each time a new value is generated by the animation, the current frame is marked as dirty, which causes the build()
method of the widget to be called again. In the build()
method, the width and height of the Image
are adjusted since they now use animation.value
, causing the image to gradually enlarge. It is important to release the controller (by calling the dispose()
method) when the animation is completed to prevent memory leaks.
In this example, we haven't specified a Curve
, so the enlargement happens linearly (at a constant speed). Now, let’s specify a Curve
to implement an animation with a spring effect. We just need to modify the code in the initState
method as follows:
@override initState() { super.initState(); controller = AnimationController( duration: const Duration(seconds: 3), vsync: this); // Using an elastic curve animation = CurvedAnimation(parent: controller, curve: Curves.bounceIn); // The width and height of the image grow from 0 to 300 animation = Tween(begin: 0.0, end: 300.0).animate(animation) ..addListener(() { setState(() => {}); }); // Start the animation controller.forward(); }
The effect after running is shown in Figure :
2. Simplifying with AnimatedWidget
Careful readers may have noticed that in the previous example, the step of updating the UI via addListener()
and setState()
is quite common. It would be cumbersome to add this line for every animation. The AnimatedWidget
class encapsulates the details of calling setState()
and allows us to separate out the widget. The refactored code is as follows:
import 'package:flutter/material.dart'; class AnimatedImage extends AnimatedWidget { const AnimatedImage({ Key? key, required Animation<double> animation, }) : super(key: key, listenable: animation); @override Widget build(BuildContext context) { final animation = listenable as Animation<double>; return Center( child: Image.asset( "imgs/avatar.png", width: animation.value, height: animation.value, ), ); } } class ScaleAnimationRoute1 extends StatefulWidget { const ScaleAnimationRoute1({Key? key}) : super(key: key); @override _ScaleAnimationRouteState createState() => _ScaleAnimationRouteState(); } class _ScaleAnimationRouteState extends State<ScaleAnimationRoute1> with SingleTickerProviderStateMixin { late Animation<double> animation; late AnimationController controller; @override initState() { super.initState(); controller = AnimationController( duration: const Duration(seconds: 2), vsync: this, ); // The image's width and height grow from 0 to 300 animation = Tween(begin: 0.0, end: 300.0).animate(controller); // Start the animation controller.forward(); } @override Widget build(BuildContext context) { return AnimatedImage( animation: animation, ); } @override dispose() { // Release animation resources when the route is destroyed controller.dispose(); super.dispose(); } }
3. Refactoring with AnimatedBuilder
AnimatedWidget
allows us to separate the widget from the animation, but the rendering process of the animation (such as setting width and height) still remains in AnimatedWidget
. Suppose we want to add an animation where the opacity of a widget changes—then we'd have to implement another AnimatedWidget
, which isn't very elegant. If we could also abstract the rendering process, it would be much better. This is where AnimatedBuilder
comes in, as it separates the rendering logic. We can modify the build()
method's code as follows:
@override Widget build(BuildContext context) { // return AnimatedImage(animation: animation); return AnimatedBuilder( animation: animation, child: Image.asset("imgs/avatar.png"), builder: (BuildContext ctx, child) { return Center( child: SizedBox( height: animation.value, width: animation.value, child: child, ), ); }, ); }
One confusing aspect of the code above is that it looks like child
is being specified twice. What actually happens is that the external child
is passed to the AnimatedBuilder
, which then passes it to the anonymous constructor, where it is used as its child widget. The final result is that the object returned by AnimatedBuilder
is inserted into the widget tree.
You might think that this isn’t much different from our initial example, but it brings three key advantages:
You no longer need to explicitly add frame listeners and call
setState()
—this advantage is similar toAnimatedWidget
.Better performance: The range of widgets that need to be rebuilt for each frame of the animation is reduced. Without
builder
,setState()
would be called in the context of the parent widget, causing the parent'sbuild()
method to be invoked again. Withbuilder
, only the animated widget’sbuild()
is called, avoiding unnecessary rebuilds.Reusability: Using
AnimatedBuilder
, you can encapsulate common transition effects to reuse animations. Below is an example of how to encapsulate aGrowTransition
, which can apply a scale animation to its child widget:
class GrowTransition extends StatelessWidget { const GrowTransition({ Key? key, required this.animation, this.child, }) : super(key: key); final Widget? child; final Animation<double> animation; @override Widget build(BuildContext context) { return Center( child: AnimatedBuilder( animation: animation, builder: (BuildContext context, child) { return SizedBox( height: animation.value, width: animation.value, child: child, ); }, child: child, ), ); } }
With this, the initial example can be modified as follows:
Widget build(BuildContext context) { return GrowTransition( child: Image.asset("images/avatar.png"), animation: animation, ); }
In Flutter, many animations are encapsulated in this way, such as FadeTransition
, ScaleTransition
, SizeTransition
, etc. In many cases, you can reuse these pre-built transition classes.
63.2 Animation Status Listener
As mentioned earlier, we can add a listener for changes in animation status through the addStatusListener()
method of Animation
. In Flutter, there are four animation states defined in the AnimationStatus
enum. Below is an explanation of each:
Enum Value | Meaning |
---|---|
dismissed | The animation is stopped at the start. |
forward | The animation is currently playing forward. |
reverse | The animation is currently playing in reverse. |
completed | The animation has stopped at the end. |
Example
We will modify the previous example of image scaling to make it cycle through enlarging and shrinking. To achieve this, we only need to listen for changes in animation status: when the animation finishes playing forward, we reverse it, and when it finishes reversing, we play it forward again. The code is as follows:
initState() { super.initState(); controller = AnimationController( duration: const Duration(seconds: 1), vsync: this, ); // The image's width and height grow from 0 to 300 animation = Tween(begin: 0.0, end: 300.0).animate(controller); animation.addStatusListener((status) { if (status == AnimationStatus.completed) { // Reverse the animation when it finishes controller.reverse(); } else if (status == AnimationStatus.dismissed) { // Play the animation forward when it returns to the initial state controller.forward(); } }); // Start the animation (play forward) controller.forward(); }