22 How to Construct Stunning Animation Effects

22 How to Construct Stunning Animation Effects #

Hello, I am Chen Hang.

In the previous article, I showed you two ways to implement page routing in Flutter: basic routing and named routing. They involve manually creating pages for switching and using identifiers provided by pre-registered routes for navigation. In addition, Flutter supports passing parameters when opening or closing pages based on these two routing methods, allowing for more precise control of route switching.

Through the learning in previous articles 12, 13, 14, and 15, we have already gained the basic skills to develop a small, beautifully-styled app. However, users no longer settle for just functional products when it comes to the appearance of terminal interfaces. They also expect good interaction, fun, and natural experiences.

Animation is an important way to enhance the user experience. A suitable component or page transition animation can not only alleviate the users’ emotional issues caused by waiting, but also create a positive impression. Since Flutter fully takes control of the rendering layer, it naturally provides support for component animations in addition to static page layouts.

Therefore, in today’s article, I will introduce the implementation methods of animations in Flutter and show you how to bring our pages to life.

Animation, AnimationController, and Listener #

Animation is the moving image that changes over time based on predefined rules. It relies on the persistence of vision to create the illusion of motion. In the context of animation systems, there are three main tasks:

  1. Determining the rules for the image changes.
  2. Setting the animation duration and starting the animation based on the rules.
  3. Periodically obtaining the current animation value and continuously adjusting and redrawing the image.

In Flutter, these three tasks are handled by Animation, AnimationController, and Listener, respectively:

  1. Animation is the core class in the Flutter animation library. It continuously outputs the current state of the animation based on predefined rules and time units. Animation knows the current state of the animation (e.g. whether it is started, stopped, forward, or backward, as well as the current value of the animation), but it doesn’t know which component object these states should be applied to. In other words, Animation only provides the animation data and is not responsible for rendering the animation.
  2. AnimationController is used to manage Animation. It can be used to set the animation duration, start, pause, or reverse the animation, etc.
  3. Listener is the callback function of Animation, used to listen to the progress changes of the animation. In this callback function, we can re-render the component based on the current animation value to achieve animation rendering.

Next, let’s look at a specific example: making the Flutter Logo in the center of the screen grow from small to large.

Firstly, we initialize an AnimationController object that manages the animation with a duration of 1 second. We also create an Animation object using a linear scale Tween, which changes from 50 to 200.

Then, we set a progress listener for this Animation object and force a redraw of the interface in the listener callback to update the animation status.

Finally, we call the forward() method of the AnimationController object to start the animation:

class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
  AnimationController controller;
  Animation<double> animation;
  @override
  void initState() {
    super.initState();
    // Create an AnimationController object with a duration of 1 second
    controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 1000));
    // Create an Animation object with a linear scale from 50 to 200
    animation = Tween(begin: 50.0, end: 200.0).animate(controller)
      ..addListener(() {
        setState(() {}); // Redraw the interface
      });
    controller.forward(); // Start the animation
  }
...
}

It is worth noting that when creating the AnimationController, we set the vsync property. This property is used to prevent invisible animations. The vsync object binds the animation to a Widget, so when the Widget is not visible, the animation will pause. When the Widget is displayed again, the animation will resume, avoiding unnecessary resource consumption when the animation component is not on the current screen.

As mentioned earlier, Animation is only responsible for providing animation data and is not responsible for rendering the animation. Therefore, we also need to retrieve the current animation state value in the build method of the Widget and use it to set the width and height of the Flutter Logo container to achieve the final animation effect:

@override
@override
Widget build(BuildContext context) {
  return MaterialApp(
    home: Center(
      child: Container(
      width: animation.value, // Assign the animation value to the width and height of the widget
      height: animation.value,
      child: FlutterLogo()
    )));
}

Finally, don’t forget to release the animation resources when the page is disposed:

@override
void dispose() {
  controller.dispose(); // Release resources
  super.dispose();
}

Let’s run the code and see the Flutter Logo in action:

Figure 1 Animation example

In the above example, we used the default linear Tween. However, it is possible to create non-linear curve animations using CurvedAnimation. CurvedAnimation provides many commonly used curves, such as the elasticOut curve:

// Create an AnimationController object with a duration of 1 second
controller = AnimationController(
    vsync: this, duration: const Duration(milliseconds: 1000));

// Create an oscillating curve
final CurvedAnimation curve = CurvedAnimation(
    parent: controller, curve: Curves.elasticOut);

// Create an Animation object that follows the curve and changes from 50 to 200
animation = Tween(begin: 50.0, end: 200.0).animate(curve)

Let’s run the code and see the Flutter Logo with an elastic animation:

Figure 2 CurvedAnimation example

Now the problem is that these animations only run once. If we want them to repeat like a heartbeat, there are two methods:

  1. Use repeat(reverse: true) when starting the animation to make it repeat back and forth.
  2. Listen to the animation state. When the animation completes, reverse it; when the reverse animation completes, start it again.

Below is the implementation code:

// Equivalent statements
// First method:
controller.repeat(reverse: true); // Repeat the animation

// Second method:
animation.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      controller.reverse(); // Reverse the animation when it completes
    } else if (status == AnimationStatus.dismissed) {
      controller.forward(); // Start the animation again when the reverse animation completes
    }
});
controller.forward(); // Start the animation

Run the code and see the Flutter Logo pulsate:

Figure 3 Flutter Logo heartbeat effect

AnimatedWidget and AnimatedBuilder #

In the process of adding animation effects to Widgets, we often find that Animation only provides animation data. Therefore, we need to listen to the progress of the animation and force refresh the UI using setState in the callback in order to see the animation effects. Considering that these steps are fixed, Flutter provides two classes to simplify this process: AnimatedWidget and AnimatedBuilder.

Next, let’s take a look at how to use these two classes.

When building Widgets, AnimatedWidget binds the state of Animation to the visual style of its child Widget. To use AnimatedWidget, we need a new class that inherits from it and receives an Animation object as its initialization parameter. Then, in the build method, we read the current value of the Animation object and use it to initialize the style of the Widget.

The following example demonstrates the AnimatedWidget version of the Flutter Logo: AnimatedLogo inherits from AnimatedWidget, and in the build method, the value of the animation is bound to the width and height of the container:

class AnimatedLogo extends AnimatedWidget {
  AnimatedLogo({Key key, Animation<double> animation})
      : super(key: key, listenable: animation);

  Widget build(BuildContext context) {
    final Animation<double> animation = listenable;
    return Center(
      child: Container(
        height: animation.value, // Update width and height based on the current state of the animation
        width: animation.value,
        child: FlutterLogo(),
      ),
    );
  }
}

When using it, we only need to pass the Animation object to AnimatedLogo, and no longer need to listen for the progress of the animation and refresh the UI:

MaterialApp(
  home: Scaffold(
    body: AnimatedLogo(animation: animation), // Pass the animation object when initializing the AnimatedWidget
  ),
);

In the above example, in the build method of AnimatedLogo, we use the value of Animation as the width and height of the logo. This works fine for simple component animations, but if the animated component is more complex, a better solution is to separate the animation and rendering responsibilities: pass the logo as an external parameter to only display it, and let another class manage the animation of the size.

We can use AnimatedBuilder to complete this separation task.

Similar to AnimatedWidget, AnimatedBuilder also automatically listens to changes in the Animation object and marks the widget tree as dirty to automatically refresh the UI as needed. In fact, if you look at the source code, you will find that AnimatedBuilder actually also inherits from AnimatedWidget.

Let’s demonstrate how to use AnimatedBuilder with an example. In this example, the size animation of AnimatedBuilder is managed by the builder function, and rendering is handled by the external child parameter:

MaterialApp(
  home: Scaffold(
    body: Center(
      child: AnimatedBuilder(
        animation: animation, // Pass the animation object
        child: FlutterLogo(),
        builder: (context, child) => Container(
          width: animation.value, // Update UI using the current state of the animation
          height: animation.value,
          child: child, // child parameter is FlutterLogo()
        ),
      ),
    ),
  ),
);

As you can see, by using AnimatedWidget and AnimatedBuilder, the generation and rendering of animations are separated, and the work of building animations is greatly simplified.

Hero Animation #

Now that we know how to implement animation effects on a single page, how do we achieve transition animations between two pages? For example, in a social media app, when users click on a small image in the feed to view a larger image, we want to achieve a gradual zoom-in animation effect from the small image to the large image page. And when the user closes the large image, we also want to implement a reverse animation to return to the original page.

This type of shared animation effect between pages has a specific term called “Shared Element Transition”.

For Android developers, this concept is not unfamiliar. Android provides native support for this type of animation, and with just a few lines of code, you can achieve smooth transitions between components shared by two activities.

Similarly, Keynote provides a “Magic Move” feature that allows for smooth transitions between two Keynote pages.

Flutter also has a similar concept called Hero widget. With Hero, we can achieve smooth page transition effects between shared elements on two pages.

Next, let’s take a look at how to use the Hero widget through an example.

In the example below, I have defined two pages. Page 1 has a small Flutter logo at the bottom, and Page 2 has a large Flutter logo in the center. When you click on the small logo on Page 1, it will use the hero effect to transition to Page 2.

To achieve shared element transitions, we need to wrap these two components with the Hero widget and set them the same tag, “hero”. Then, add a click gesture response to Page 1, so that when the user clicks on the logo, it navigates to Page 2:

class Page1 extends StatelessWidget {
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        child: Hero(
          tag: 'hero',
          child: Container(
            width: 100, height: 100,
            child: FlutterLogo())),
        onTap: () {
          Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));
        },
      )
    );
  }
}

class Page2 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Hero(
        tag: 'hero',
        child: Container(
          width: 300, height: 300,
          child: FlutterLogo()
        ))
    );
  }
}

When you run this code, you can see that with just two simple steps, we can achieve complex animation effects where elements fly between pages!

Figure 4: Hero Animation

Summary #

Alright, that’s all for today’s sharing. Let’s briefly review the main content.

In Flutter, the state and rendering of animations are separated. We generate animation curves through Animation, control the animation time and start the animation using AnimationController. To render the animation, we need to set a listener to get the animation progress and then trigger the component to refresh with the new animation state.

To simplify this step, Flutter provides two components, AnimatedWidget and AnimatedBuilder, which eliminate the need for state listeners and UI refresh. As for cross-page animations, Flutter provides the Hero component, which allows for a transition effect of element crossing between two identical (or similar) components with the same tag.

As we can see, Flutter’s layered design for animations is very simple and clear, but the side effect is that it can be slightly more complicated to use. In actual applications, since the animation process involves frequent page refreshes, I strongly recommend using AnimatedWidget or AnimatedBuilder to narrow down the range of components affected by the animation. Only redraw the components that need animation, and avoid using progress listeners to directly refresh the entire page, so that components that do not need animation will also be destroyed and rebuilt along with it.

I have bundled the ordinary animations for widgets, AnimatedBuilder and AnimatedWidget, as well as the transition animation for pages, Hero, into a GitHub repository. You can download the project and run it multiple times to experience the specific usage of these animations.

Thought Questions #

Finally, I have two small assignments for you.

AnimatedBuilder(
  animation: animation,
  child: FlutterLogo(),
  builder: (context, child) => Container(
    width: animation.value,
    height: animation.value,
    child: child
  )
)
  1. In the example of AnimatedBuilder, it seems that “child” is assigned twice (line 3 and line 7). Can you explain the reason for doing so?

  2. If I remove “child” from line 3 and move the Flutter Logo to line 7, will the animation still work correctly? Will there be any issues?

Feel free to leave a comment below to share your thoughts. I will be waiting for you in the next article! Thank you for reading, and feel free to share this article with more friends.