跳到正文
Willh的个人博客 记录 · 思考 · 分享
《Flutter实战·第二版》

9.2 动画基本结构及状态监听

9.2.1 动画基本结构

在Flutter中我们可以通过多种方式来实现动画,下面通过一个图片逐渐放大示例的不同实现来演示Flutter中动画的不同实现方式的区别。

基础版本

下面我们演示一下最基础的动画实现方式:

1class ScaleAnimationRoute extends StatefulWidget {
2  
3  _ScaleAnimationRouteState createState() => _ScaleAnimationRouteState();
4}
5
6//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
7class _ScaleAnimationRouteState extends State<ScaleAnimationRoute>
8    with SingleTickerProviderStateMixin {
9  late Animation<double> animation;
10  late AnimationController controller;
11
12  initState() {
13    super.initState();
14    controller = AnimationController(
15      duration: const Duration(seconds: 2),
16      vsync: this,
17    );
18
19    //匀速
20    //图片宽高从0变到300
21    animation = Tween(begin: 0.0, end: 300.0).animate(controller)
22      ..addListener(() {
23        setState(() => {});
24      });
25
26    //启动动画(正向执行)
27    controller.forward();
28  }
29
30  
31  Widget build(BuildContext context) {
32    return Center(
33      child: Image.asset(
34        "imgs/avatar.png",
35        width: animation.value,
36        height: animation.value,
37      ),
38    );
39  }
40
41  dispose() {
42    //路由销毁时需要释放动画资源
43    controller.dispose();
44    super.dispose();
45  }
46}

上面代码中addListener()函数调用了setState(),所以每次动画生成一个新的数字时,当前帧被标记为脏(dirty),这会导致widget的build()方法再次被调用,而在build()中,改变Image的宽高,因为它的高度和宽度现在使用的是animation.value ,所以就会逐渐放大。值得注意的是动画完成时要释放控制器(调用dispose()方法)以防止内存泄漏。

上面的例子中并没有指定Curve,所以放大的过程是线性的(匀速),下面我们指定一个Curve,来实现一个类似于弹簧效果的动画过程,我们只需要将initState中的代码改为下面这样即可:

1  initState() {
2    super.initState();
3    controller = AnimationController(
4        duration: const Duration(seconds: 3), vsync: this);
5    //使用弹性曲线
6    animation=CurvedAnimation(parent: controller, curve: Curves.bounceIn);
7    //图片宽高从0变到300
8    animation = Tween(begin: 0.0, end: 300.0).animate(animation)
9      ..addListener(() {
10        setState(() => {});
11      });
12    //启动动画
13    controller.forward();
14  }

运行后效果如下:

图9-1

使用AnimatedWidget简化

细心的读者可能已经发现上面示例中通过addListener()和setState() 来更新UI这一步其实是通用的,如果每个动画中都加这么一句是比较繁琐的。AnimatedWidget类封装了调用setState()的细节,并允许我们将 widget 分离出来,重构后的代码如下:

1import 'package:flutter/material.dart';
2
3class AnimatedImage extends AnimatedWidget {
4  AnimatedImage({
5    Key? key,
6    required Animation<double> animation,
7  }) : super(key: key, listenable: animation);
8
9  Widget build(BuildContext context) {
10    final animation = listenable as Animation<double>;
11    return  Center(
12      child: Image.asset(
13        "imgs/avatar.png",
14        width: animation.value,
15        height: animation.value,
16      ),
17    );
18  }
19}
20
21class ScaleAnimationRoute1 extends StatefulWidget {
22  
23  _ScaleAnimationRouteState createState() =>  _ScaleAnimationRouteState();
24}
25
26class _ScaleAnimationRouteState extends State<ScaleAnimationRoute1>
27    with SingleTickerProviderStateMixin {
28  late Animation<double> animation;
29  late AnimationController controller;
30
31  initState() {
32    super.initState();
33    controller =  AnimationController(
34        duration: const Duration(seconds: 2), vsync: this);
35    //图片宽高从0变到300
36    animation =  Tween(begin: 0.0, end: 300.0).animate(controller);
37    //启动动画
38    controller.forward();
39  }
40
41  
42  Widget build(BuildContext context) {
43    return AnimatedImage(
44      animation: animation,
45    );
46  }
47
48  dispose() {
49    //路由销毁时需要释放动画资源
50    controller.dispose();
51    super.dispose();
52  }
53}

用AnimatedBuilder重构

用AnimatedWidget 可以从动画中分离出 widget,而动画的渲染过程(即设置宽高)仍然在AnimatedWidget 中,假设如果我们再添加一个 widget 透明度变化的动画,那么我们需要再实现一个AnimatedWidget,这样不是很优雅,如果我们能把渲染过程也抽象出来,那就会好很多,而AnimatedBuilder正是将渲染逻辑分离出来, 上面的 build 方法中的代码可以改为:

1
2Widget build(BuildContext context) {
3  //return AnimatedImage(animation: animation,);
4    return AnimatedBuilder(
5      animation: animation,
6      child: Image.asset("imgs/avatar.png"),
7      builder: (BuildContext ctx, child) {
8        return  Center(
9          child: Container(
10            height: animation.value,
11            width: animation.value,
12            child: child,
13          ),
14        );
15      },
16    );
17}

上面的代码中有一个迷惑的问题是,child看起来像被指定了两次。但实际发生的事情是:将外部引用child传递给AnimatedBuilder后,AnimatedBuilder再将其传递给匿名构造器, 然后将该对象用作其子对象。最终的结果是AnimatedBuilder返回的对象插入到 widget 树中。

也许你会说这和我们刚开始的示例差不了多少,其实它会带来三个好处:

  1. 不用显式的去添加帧监听器,然后再调用setState() 了,这个好处和AnimatedWidget是一样的。

  2. 更好的性能:因为动画每一帧需要构建的 widget 的范围缩小了,如果没有builder,setState()将会在父组件上下文中调用,这将会导致父组件的build方法重新调用;而有了builder之后,只会导致动画widget自身的build重新调用,避免不必要的rebuild。

  3. 通过AnimatedBuilder可以封装常见的过渡效果来复用动画。下面我们通过封装一个GrowTransition来说明,它可以对子widget实现放大动画:

    1class GrowTransition extends StatelessWidget {
    2  GrowTransition({
    3    required this.animation,
    4    this.child,
    5  });
    6
    7  final Widget? child;
    8  final Animation<double> animation;
    9
    10  Widget build(BuildContext context) {
    11    return Center(
    12      child: AnimatedBuilder(
    13        animation: animation,
    14        builder: (BuildContext context, child) {
    15          return Container(
    16            height: animation.value,
    17            width: animation.value,
    18            child: child,
    19          );
    20        },
    21        child: child,
    22      ),
    23    );
    24  }
    25}

    这样,最初的示例就可以改为:

    1...
    2Widget build(BuildContext context) {
    3  return GrowTransition(
    4    child: Image.asset("images/avatar.png"), 
    5    animation: animation,
    6  );
    7}

    Flutter中正是通过这种方式封装了很多动画,如:FadeTransition、ScaleTransition、SizeTransition等,很多时候都可以复用这些预置的过渡类。

9.2.2 动画状态监听

上面说过,我们可以通过Animation的addStatusListener()方法来添加动画状态改变监听器。Flutter中,有四种动画状态,在AnimationStatus枚举类中定义,下面我们逐个说明:

枚举值 含义
dismissed 动画在起始点停止
forward 动画正在正向执行
reverse 动画正在反向执行
completed 动画在终点停止

示例

我们将上面图片放大的示例改为先放大再缩小再放大……这样的循环动画。要实现这种效果,我们只需要监听动画状态的改变即可,即:在动画正向执行结束时反转动画,在动画反向执行结束时再正向执行动画。代码如下:

1  initState() {
2    super.initState();
3    controller = AnimationController(
4      duration: const Duration(seconds: 1), 
5      vsync: this,
6    );
7    //图片宽高从0变到300
8    animation = Tween(begin: 0.0, end: 300.0).animate(controller);
9    animation.addStatusListener((status) {
10      if (status == AnimationStatus.completed) {
11        //动画执行结束时反向执行动画
12        controller.reverse();
13      } else if (status == AnimationStatus.dismissed) {
14        //动画恢复到初始状态时执行动画(正向)
15        controller.forward();
16      }
17    });
18
19    //启动动画(正向)
20    controller.forward();
21  }