62.1 Basic Principles of Animation
In any UI framework, the principle of implementing animations is the same: rapidly change the UI appearance multiple times over a period. Due to the phenomenon of visual persistence, what we ultimately see is a "continuous" animation, similar to the principle of film. A single change in the UI is referred to as an animation frame, corresponding to a screen refresh. A key indicator of animation smoothness is the frame rate (FPS - Frames Per Second), which represents the number of animation frames per second. Clearly, a higher frame rate results in smoother animations! Generally, for the human eye, an animation frame rate above 16 FPS is perceivable, and above 32 FPS feels relatively smooth, while most people cannot discern differences above that rate. Since each frame of the animation requires changes to the UI output, continuously changing UI output within a time frame is resource-intensive, demanding higher specifications from the device's hardware and software systems. Therefore, the average frame rate of animations is an important performance metric in UI systems. In Flutter, ideally, it can achieve 60 FPS, which is on par with native applications.
62.2 Animation Abstraction in Flutter
To facilitate developers in creating animations, different UI systems provide abstractions for animations. For example, in Android, animations can be described using XML and assigned to a View. Flutter also abstracts animations, mainly involving four components: Animation, Curve, Controller, and Tween, which work together to complete a full animation. Below, we introduce them one by one.
1. Animation
Animation
is an abstract class that does not directly relate to UI rendering. Its main function is to store the interpolation and state of the animation. One commonly used Animation
class is Animation<double>
. The Animation
object generates a series of values over a period between a range (Tween). The values output by the Animation
object during the animation can be linear, curved, a step function, or any other curve function, determined by the Curve
. Depending on how the Animation
object is controlled, the animation can run forward (from the starting state to the ending state), backward, or even switch directions in the middle. Animation
can also generate other types of values besides double, such as Animation<Color>
or Animation<Size>
. In each frame of the animation, we can obtain the current state value through the value
property of the Animation
object.
Animation Notifications
We can use Animation
to listen for changes in each frame and the execution state of the animation. Animation
has the following two methods:
addListener()
: This adds a frame listener to theAnimation
, which will be called in every frame. A common action in the frame listener is to callsetState()
after changing the state to trigger a UI rebuild.addStatusListener()
: This adds a listener for "animation status changes"; it will be called when the animation starts, ends, runs forward, or backward (seeAnimationStatus
definition).
Readers only need to understand the difference between frame listeners and status listeners; examples will be provided in later chapters.
2. Curve
The animation process can be uniform, uniformly accelerated, or start with acceleration and then decelerate. Flutter uses Curve
to describe the animation process; uniform animations are termed linear (Curves.linear
), while non-uniform animations are referred to as non-linear.
We can specify the animation curve using CurvedAnimation
, for example:
final CurvedAnimation curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
CurvedAnimation
and AnimationController
(introduced below) are both of type Animation<double>
. CurvedAnimation
wraps AnimationController
and Curve
to create a new animation object, associating the animation with its execution curve. We specify the curve as Curves.easeIn
, which means the animation starts slowly and ends quickly. The Curves
class is a preset enumeration that defines many commonly used curves; here are a few:
Curves | Animation Process |
---|---|
linear | Uniform speed |
decelerate | Uniform deceleration |
ease | Accelerates, then decelerates |
easeIn | Starts slow, ends fast |
easeOut | Starts fast, ends slow |
easeInOut | Starts slow, accelerates, then decelerates |
Besides the curves listed above, the Curves
class defines many other curves; readers can refer to the Curves
class definition for more.
Of course, we can create our own Curve
. For instance, we can define a sine curve:
class ShakeCurve extends Curve { @override double transform(double t) { return math.sin(t * math.PI * 2); } }
3. AnimationController
AnimationController
is used to control animations and includes methods for starting (forward()
), stopping (stop()
), and reversing playback (reverse()
). The AnimationController
generates a new value in every frame of the animation. By default, it linearly generates numbers from 0.0 to 1.0 (the default range) over a given duration. For example, the following code creates an Animation
object (but does not start the animation):
final AnimationController controller = AnimationController( duration: const Duration(milliseconds: 2000), vsync: this, );
The range of numbers generated by AnimationController
can be specified using lowerBound
and upperBound
, for example:
final AnimationController controller = AnimationController( duration: const Duration(milliseconds: 2000), lowerBound: 10.0, upperBound: 20.0, vsync: this );
AnimationController
derives from Animation<double>
, so it can be used wherever an Animation
object is needed. However, AnimationController
has additional methods for controlling animations, such as forward()
to start a forward animation and reverse()
to start a backward animation. After the animation begins, it starts generating animation frames. Each screen refresh corresponds to an animation frame, and in each frame of the animation, it generates the current animation value (Animation.value
) based on the animation curve, which is then used to build the UI. As all animation frames are triggered sequentially, the animation values change in sequence, leading to a visible completed animation. Additionally, in each frame of the animation, the Animation
object will call its frame listeners, and when the animation state changes (e.g., when the animation ends), it will call the status change listeners.
The duration
parameter specifies how long the animation runs, allowing us to control the speed of the animation.
Note: In some cases, the animation value may exceed the range of [0.0, 1.0] defined by AnimationController
, depending on the specific curve. For instance, the fling()
function can simulate a finger flicking animation based on the speed (velocity
), force, etc., causing its animation value to fall outside the range of [0.0, 1.0]. In other words, depending on the chosen curve, the output of CurvedAnimation
can have a greater range than the input. For example, elastic curves like Curves.elasticIn
will produce values greater than or less than the default range.
Ticker
When creating an AnimationController
, a vsync
parameter must be passed, which takes an object of type TickerProvider
. Its main responsibility is to create a Ticker
, defined as follows:
abstract class TickerProvider { // Create a Ticker via a callback Ticker createTicker(TickerCallback onTick); }
Flutter applications bind a SchedulerBinding
upon startup. Through SchedulerBinding
, a callback is added for every screen refresh, and the Ticker
adds this screen refresh callback, meaning every screen refresh will invoke the TickerCallback
. Using a Ticker
(instead of a Timer
) to drive animations prevents unnecessary resource consumption from off-screen animations (animations where the UI is not currently visible, such as when the screen is locked) because Flutter notifies the bound SchedulerBinding
when the screen refreshes. Since the screen stops refreshing when locked, the Ticker
will no longer trigger.
Typically, we add SingleTickerProviderStateMixin
to the definition of State
, using the State
object as the value for vsync
, as will be seen in later examples.
4. Tween
1) Introduction
By default, the value range of an AnimationController
object is [0.0, 1.0]. If we need to construct animation values for the UI in a different range or data type, we can use Tween
to map and generate values in a different range or type. For example, the following Tween
generates values between [-200.0, 0.0]:
final Tween<double> doubleTween = Tween<double>(begin: -200.0, end: 0.0);
The Tween
constructor requires two parameters: begin
and end
. The sole responsibility of a Tween
is to define a mapping from an input range to an output range. The input range is typically [0.0, 1.0], but this is not mandatory, and we can customize the range as needed.
Tween
inherits from Animatable<T>
, not from Animation<T>
. The Animatable
class mainly defines the rules for mapping animation values.
Next, let's look at an example where a ColorTween
maps the animation input range to transition between two colors:
final ColorTween colorTween = ColorTween(begin: Colors.transparent, end: Colors.black54);
Tween
objects do not store any state. Instead, they provide an evaluate(Animation<double> animation)
method, which can retrieve the current mapped value of the animation. The current value of the Animation
object can be accessed through the value()
method. The evaluate
function also handles some additional processing, such as ensuring that at 0.0 and 1.0, the start and end states are returned, respectively.
2) Tween.animate
To use a Tween
object, you need to call its animate()
method and pass in a controller object. For example, the following code generates integer values between 0 and 255 over 500 milliseconds:
final AnimationController controller = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);
Note that animate()
returns an Animation
, not an Animatable
.
The following example constructs a controller, a curve, and a Tween
:
final AnimationController controller = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); final Animation curve = CurvedAnimation(parent: controller, curve: Curves.easeOut); Animation<int> alpha = IntTween(begin: 0, end: 255).animate(curve);
62.3 Linear Interpolation (lerp) Function
The principle of animation is to draw different content in each frame, typically by specifying the start and end states, and then transitioning from the start state to the end state over a period of time. The state value for each specific frame is calculated based on the animation progress. Therefore, many properties in Flutter that may require animation define static lerp
methods (linear interpolation). For example:
// a is the starting color, b is the ending color, t is the current animation progress [0, 1] Color.lerp(a, b, t);
The calculation of lerp
typically follows this formula:return value = a + (b - a) * t
. Other classes that have lerp
methods include:
// Size.lerp(a, b, t) // Rect.lerp(a, b, t) // Offset.lerp(a, b, t) // Decoration.lerp(a, b, t) // Tween.lerp(t) // The start and end states are defined when the Tween is constructed.
It is important to note that lerp
is linear interpolation, which means that the returned value has a linear relationship with the animation progress t
, described by the formula y = kx + b
. Since the graph of a linear function is a straight line, it is called linear interpolation. If we want the animation to follow a curve, we can map t
accordingly. For instance, to achieve uniform acceleration, we could map t
using a quadratic function, such as t' = at² + bt + c
, and then specify the acceleration a
and b
. (In most cases, t'
should remain within the range [0, 1], although some cases, such as a bounce effect, may exceed this range.) The essence of different curves, such as those defined by Curve
, is that they map t
according to different mathematical functions, resulting in different animation behaviors.