Flutter (63): Basic animation structure and status monitoring

Time: Column:Mobile & Frontend views:282

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 :

Flutter (63): Basic animation structure and status monitoring

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:

  1. You no longer need to explicitly add frame listeners and call setState()—this advantage is similar to AnimatedWidget.

  2. 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's build() method to be invoked again. With builder, only the animated widget’s build() is called, avoiding unnecessary rebuilds.

  3. Reusability: Using AnimatedBuilder, you can encapsulate common transition effects to reuse animations. Below is an example of how to encapsulate a GrowTransition, 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 ValueMeaning
dismissedThe animation is stopped at the start.
forwardThe animation is currently playing forward.
reverseThe animation is currently playing in reverse.
completedThe 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();
}