Flutter (62): Introduction to Flutter animation

Time: Column:Mobile & Frontend views:229

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 the Animation, which will be called in every frame. A common action in the frame listener is to call setState() 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 (see AnimationStatus 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:

CurvesAnimation Process
linearUniform speed
decelerateUniform deceleration
easeAccelerates, then decelerates
easeInStarts slow, ends fast
easeOutStarts fast, ends slow
easeInOutStarts 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.