只有记在脑海里的才是自己的,如果还没来得及,请写下来
flutter 刻意学习animation动画
2020-12-05 / 13 min read

在使用Animation之前,先了解一下,要完成一个完整的动画,需要用到哪些结构(类)

  1. Animation :Animation是一个抽象类,定义abstract class Animation<T> extends Listenable implements ValueListenable<T>,继承 Listenable类(可以添加监听和移除监听);实现 ValueListenable 接口(存在一个get value 的方法)

  2. Curve:曲线,描述动画的过程,比如匀速执行,加速执行,正弦、余弦函数等执行;在 class Curves中可以找到一些常用的曲线:

class Curves {
  static const Curve linear = _Linear._();
  static const Curve decelerate = _DecelerateCurve._();
  static const Cubic fastLinearToSlowEaseIn = Cubic(0.18, 1.0, 0.04, 1.0);
  static const Cubic ease = Cubic(0.25, 0.1, 0.25, 1.0);
  ...
}
  1. AnimationController:继承Animation<double>用来控制动画启动forward()、停止stop() 、反向播放 reverse()等,AnimationController 会在每一帧生成一个值(在指定的时间段内线性的生成从0.0到1.0的值),创建一个AnimationController:
AnimationController controller = AnimationController(duration: Duration(seconds: 3), vsync: this);
在三秒钟内,每一帧生成 0.0 到 0.1 的值

一:绑定曲线与动画

在上面,我们讲了一个动画的主要组成结构,接下来,我们要将这些结构组合形成动画。

1.1:CurvedAnimation 绑定曲线与动画

CurvedAnimationAnimationController一样也继承 Animation<double>,下面开始使用CurvedAnimation来连接曲线与动画

  1. 创建 AnimationController
AnimationController _controller;
_controller = AnimationController(duration: Duration(seconds: 60), vsync: this);
// 这里了解一下 AnimationController 的参数
duration: 动画执行的时长,就是动画需要在60秒的时间内完成
vsync:TickerProvider 类型,监听屏幕刷新的回调,因为动画本质就是每帧绘制不同的界面来实现的。所以我们理解,动画需要和屏幕的刷新帧数关联,一般我们通过 with SingleTickerProviderStateMixin 来实现
  1. 创建 CurvedAnimation
CurvedAnimation _animation;
_animation = CurvedAnimation(curve: Curves.ease, parent: _controller);
// CurvedAnimation 的参数了解
curve:曲线,需要传入一个曲线对象,用来表示动画是匀速、加速等执行效果
parent:这里传入动画控制 AnimationController,就是绑定曲线与动画

二:呈现动画(一般有三种方式)

上面我们通过 CurvedAnimation 已经将 动画与曲线绑定在一起了:在指定的时间内,每帧都会按照指定的曲线生成一个值value,这个value就是我们控制动画效果的值

2.1:第一种方式实现动画

我们可以通过监听动画的value来实时去刷新界面,这样就可以实现动画的效果了,监听动画的value,然后不断去刷新界面,前面讲到 Animation继承 Listenable,所以我们可以添加如下监听:

_animation.addListener(() {
    setState(() {  });
});

完整的实列如下:点击按钮,动画开始执行,Icon会不断放大,并显示出动画的value值

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin  {
  AnimationController _controller;
  CurvedAnimation _animation;
  @override
  void initState() {
    super.initState();
      _controller = AnimationController(duration: Duration(seconds: 20), vsync: this);
      _animation = CurvedAnimation(curve: Curves.ease, parent: _controller);
      _animation.addListener(() {
        setState(() {
          
        });
      });
  }

  void _incrementCounter() {
    if (_controller.isAnimating) {
      _controller.stop();
    }else {
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '${_animation.value}',
            ),
            Icon(Icons.home, size: 24 + _animation.value * 100)
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.star),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

2.2:第二种方式 AnimatedWidget

在 2.1 中我们利用Animation的监听,然后不断调用 setState 方法实现了动画效果,那么有没有更优雅的方式?
使用 AnimatedWidget简化一下,需要继承 AnimatedWidget
第一步:将存在动画效果的 widge 进行拆分,上面的效果在 Icon 上,我们就将 Icon 拆出来:

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

  @override
  Widget build(BuildContext context) {
    final Animation<double> animation = listenable;
    return Icon(
      Icons.home,
      size: 24 + animation.value * 100
    );
  }
}

下面开始使用IconAnimatedWidget,修改 build 方法如下:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            IconAnimatedWidget(
              animation: _animation,
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.star),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

可以看出,使用 AnimatedWidget 之后,我们不需要去手动监听和调用setState;主要是 AnimatedWidget在内部帮我做了这些操作,我们可以找到 AnimatedWidget 对应的状态管理 _AnimatedState部分代码如下:

class _AnimatedState extends State<AnimatedWidget> {
  @override
  void initState() {
    super.initState();
    widget.listenable.addListener(_handleChange);
  } 
  ...
  void _handleChange() {
    setState(() {
      // The listenable's state is our build state, and it changed already.
    });
  }
  ...
}

2.3:第三种方式 AnimatedBuilder

如果我们不想将 Animation 的 widget 抽离出去,但是又不想去显示的调用 setState,那么我们还可以借助 AnimatedBuilder 来实现动画效果。

注意:AnimatedBuilder也是继承AnimatedWidget

AnimatedBuilder的构建方法如下:

class AnimatedBuilder extends AnimatedWidget {
  /// Creates an animated builder.
  ///
  /// The [animation] and [builder] arguments must not be null.
  const AnimatedBuilder({
    Key? key,
    required Listenable animation,
    required this.builder,
    this.child,
  }) : assert(animation != null),
       assert(builder != null),
       super(key: key, listenable: animation);
// 参数了解
animation:与2.2 中一样,我们需要传入一个动画
child:这里传入child,其实是为了防止child被多次build
builder:回调函数,需要我们返回一个 widget(这个 widget 就是我们需要执行动画的widget )
 ...
}

现在我们利用 AnimatedBuilder 改写上面的代码:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            AnimatedBuilder(
              animation: _animation,
              builder: (context, child) {
                return Icon(
                  Icons.home,
                  size: 24 + _animation.value * 100,
                );
              },
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.star),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

这里我们使用 AnimatedBuilder,但是未指定他的child,是因为我们的动画效果就在builder中返回的 widget上,如果不是在返回的 widget 上,我们通常如下使用:

AnimatedBuilder(
    animation: _animation,
    child: Icon(Icons.home),
    builder: (context, child) {
    return Container(
        width: 24 + _animation.value * 100,
        height: 24 + _animation.value * 100,
        child: child,
        color: Colors.green,
    );
    },
)

这样使用的话,意味着Container使用了动画,不断变大,但是Icon(Icons.home)的大小不会改变。
这样的好处:就是动画每帧重构(build)被限定在 builder 回调函数中,并且指定的 child 被保存起来,不会被重构(build) 。这里只对Container这个widget进行了重构(build),而内部的child 只是赋值,并不会执行重构(build)函数。

现在我们比对一下以上三种方式:

  1. 第一种方式比较直观:手动设置监听调用setState方法,通常很少用,因为看起来比较low,而且每帧都需要 build 整个界面
  2. 第二种方式适合用来拆分widget、比如复杂的界面,通常都需要我们去拆分出一些单独的widget
  3. 第三种方式适合简单的界面,如果觉得不需要去拆分成多个widget,此方式比较适合;另外如果需要执行动画的 widget 存在子widget,且子widget不需要跟随动画改变,此方式也非常适合
  4. 第二种和第三种有明显的优势,因为AnimatedBuilder 继承 StatefulWidget,则每帧需要重新 build 只会执行 AnimatedBuilder 的 build 方法,而不需要 build 当前整个界面;

三:Animation

在上面的操作中,我们提到 AnimationController 继承 Animation,所有我们在接收animation.value 时都是 double 类型,那么如果我想动画改变颜色、padding或者其他该怎么办?

3.1:Tween 映射 Animation 的value 值

先了解Tween的定义,Tween 继承 Animatable,在 Animatable抽象类中,我们可以看到官方的解释: 给定[Animation]作为输入,通常画的值名义上在0.0到1.0范围内,但是原则上可以适合类型的值;意思就是[Animation]作为输入可以输出[Animation类型] ,内部提供一个抽象方法 T transform(double t);;接着我们去看 Tween,在 Tween对抽象方法进行了实现

  @override
  T transform(double t) {
    if (t == 0.0)
      return begin as T;
    if (t == 1.0)
      return end as T;
    return lerp(t);
  }

但是通常我们使用 Tween的子类,比如 ColorTweenSizeTween...

我们可以通过Tween类将animation.value的值映射成其他类型,比如Colors,我们创建如下 Tween
final Tween colorTween = ColorTween(begin: Colors.red, end: Colors.black);
比如RectTween,
final Tween paddingTween = RectTween(begin: Rect.zero, end: Rect.fromLTRB(10, 10, 10, 10));

3.2:将 Tween 绑定到动画中

调用 Animatable提供的 animate() 方法,我们可以将 Tween绑定到动画中,动画20秒内由红色变成黑色

  1. 不指定动画曲线绑定:
final AnimationController controller = AnimationController(duration: Duration(seconds: 20), vsync: this);
final Tween colorTween = ColorTween(begin: Colors.red, end: Colors.black);
final Animation<Color> colorAnimation = colorTween.animate(controller);
  1. 指定动画曲线绑定:
final AnimationController controller = AnimationController(duration: Duration(seconds: 20), vsync: this);
CurvedAnimation curvedAnimation = CurvedAnimation(curve: Curves.easeIn, parent: controller);
final Tween colorTween = ColorTween(begin: Colors.red, end: Colors.black);
final Animation<Color> colorAnimation = colorTween.animate(curvedAnimation);

3.3:使用 colorAnimation 的 value

替换上面的 animation.value,改为 colorAnimation.value 如下:

AnimatedBuilder(
    animation: _curvedAnimation,
    child: Icon(Icons.home),
    builder: (context, child) {
    return Container(
        width: 100,
        height: 100,
        child: child,
        color: _colorAnimation.value,
    );
    },
)

效果截图:

###四:动画状态

动画执行执行后,我们可能需要监听动画的执行状态,动画是否完成、动画完成后停止在开头还是结尾等...

在前面,我们知道 Animation类有监听动画帧回调void addListener(VoidCallback listener);的方法,其实还有有个方法 void addStatusListener(AnimationStatusListener listener);,我们可以通过设置此监听方法得到动画的执行状态:

_colorAnimation.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
        print('动画执行完毕,停止在动画结束');
        // 动画执行完成,反向执行此动画
        _controller.reverse();
    }else if (status == AnimationStatus.dismissed) {
        print('动画执行完毕,停止在动画开始');
        // 动画执行完成,正向执行此动画
        _controller.forward();
    }else if (status == AnimationStatus.forward) {
        print('动画从开始到结束,正向执行');
    }else if (status == AnimationStatus.reverse) {
        print('动画从结束到开始,反向执行');
    }
});

关于 AnimationStatus的枚举类型:

/// The status of an animation.
enum AnimationStatus {
  /// The animation is stopped at the beginning.
  dismissed,        // 动画在起始点停止
  /// The animation is running from beginning to end.
  forward,          // 动画正在正向执行
  /// The animation is running backwards, from end to beginning.
  reverse,          // 动画正在反向执行
  /// The animation is stopped at the end.
  completed,     // 动画在终点停止
}

控制动画的一些常用方法:

controller.forward();      // 正向执行此动画
controller.reverse();       // 反向执行此动画
controller.stop();           // 停止执行此动画
controller.dispose();       // 销毁此动画(销毁后无法开启,一般在界面被销毁时调用)
controller.isAnimating      // 判断此动画是否正在执行

五:路由切换动画

对于页面切换,flutter提供了一个路由的管理类 Navigator,我们通过Navigator.of(context).push() 来实现页面的切换。

5.1:常用的切换方式

MaterialPageRoute 根据系统不同,展示系统的切换效果

 Navigator.of(context).push(MaterialPageRoute(builder: (context) {
    return RouterPage1();
}));

5.2:指定切换效果为iOS效果

CupertinoPageRoute指定iOS的效果

Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
    return RouterPage1();
}));

从底部弹出效果,指定fullscreenDialogtrue,采用Dialog的效果(从底部弹出)

Navigator.of(context).push(CupertinoPageRoute(
    fullscreenDialog: true,
    builder: (context) {
        return RouterPage1();
    }
));

5.3:修改为其他效果

前面提到的MaterialPageRouteCupertinoPageRoute 都系统帮我们定义好的Route(已经在内部实现了过渡动画),如果我们想要实现其他的效果,如何实现?
flutter 本身提供了很多的过渡效果,比如 FadeTransition 透明度渐变效果,我们需要使用PageRouteBuilder来获取 Route 切换的帧动画值 animation;并返回一个 FadeTransition动画效果的widget

Navigator.of(context).push(PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) {
        return FadeTransition(
            opacity: animation,
            child: RouterPage1(),
        );
    }
));