Flutter 笔记 | Flutter 动画

Flutter中的动画抽象

为了方便开发者创建动画,不同的UI系统对动画都进行了一些抽象, Flutter中也对动画进行了抽象,主要涉及 Animation、Curve、Controller、Tween这四个角色,它们一起配合来完成一个完整动画,下面我们一一来介绍它们。

1. Animation

Animation是一个抽象类,它本身和UI渲染没有任何关系,而它主要的功能是保存动画的插值和状态;其中一个比较常用的Animation类是Animation

Animation对象是一个在一段时间内依次生成一个区间(Tween)之间值的类。Animation对象在整个动画执行过程中输出的值可以是线性的、曲线的、一个步进函数或者任何其他曲线函数等等,这由Curve来决定。

根据Animation对象的控制方式,动画可以正向运行(从起始状态开始,到终止状态结束),也可以反向运行,甚至可以在中间切换方向。

Animation还可以生成除double之外的其他类型值,如:AnimationAnimation。在动画的每一帧中,我们可以通过Animation对象的value属性获取动画的当前状态值。

动画通知

我们可以通过Animation来监听动画每一帧以及执行状态的变化,Animation有如下两个方法:

  • addListener();它可以用于给Animation添加帧监听器,在每一帧都会被调用。帧监听器中最常见的行为是改变状态后调用setState()来触发UI重建。
  • addStatusListener();它可以给Animation添加“动画状态改变”监听器;动画开始、结束、正向或反向(见AnimationStatus定义)时会调用状态改变的监听器。

在此只需要知道帧监听器和状态监听器的区别,在后面我们将会举例说明。

2. Curve

动画过程可以是匀速的、匀加速的或者先加速后减速等。Flutter中通过Curve(曲线)来描述动画过程,我们把匀速动画称为线性的(Curves.linear),而非匀速动画称为非线性的。

我们可以通过CurvedAnimation来指定动画的曲线,如:

final CurvedAnimation curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);

CurvedAnimationAnimationController(下面介绍)都是Animation类型。CurvedAnimation可以通过包装AnimationControllerCurve生成一个新的动画对象 ,我们正是通过这种方式来将动画和动画执行的曲线关联起来的。我们指定动画的曲线为Curves.easeIn,它表示动画开始时比较慢,结束时比较快。 Curves 类是一个预置的枚举类,定义了许多常用的曲线,下面列几种常用的:

Curves曲线 动画过程
linear 匀速的
decelerate 匀减速
ease 开始加速,后面减速
easeIn 开始慢,后面快
easeOut 开始快,后面慢
easeInOut 开始慢,然后加速,最后再减速

除了上面列举的, Curves 类中还定义了许多其他的曲线,可自行查看Curves类定义。

当然我们也可以创建自己Curve,例如我们定义一个正弦曲线:

class ShakeCurve extends Curve {
  
  double transform(double t) {
    return math.sin(t * math.PI * 2);
  }
}

3. AnimationController

AnimationController用于控制动画,它包含动画的启动forward()、停止stop() 、反向播放 reverse()等方法。AnimationController会在动画的每一帧,就会生成一个新的值。默认情况下,AnimationController在给定的时间段内线性的生成从 0.01.0(默认区间)的数字。

例如,下面代码创建一个Animation对象(但不会启动动画):

final AnimationController controller = AnimationController(
	  duration: const Duration(milliseconds: 2000),
	  vsync: this,
);

其中,duration表示动画执行的时长,通过它我们可以控制动画的速度。

AnimationController生成数字的区间可以通过lowerBoundupperBound来指定,如:

final AnimationController controller = AnimationController( 
	 duration: const Duration(milliseconds: 2000), 
	 lowerBound: 10.0,
	 upperBound: 20.0,
	 vsync: this
);

AnimationController派生自Animation,因此可以在需要Animation对象的任何地方使用。

更重要的是,AnimationController具有控制动画的其他方法,例如forward()方法可以启动正向动画,reverse()可以启动反向动画。在动画开始执行后开始生成动画帧,屏幕每刷新一次就是一个动画帧,在动画的每一帧,会随着根据动画的曲线来生成当前的动画值(Animation.value),然后根据当前的动画值去构建UI,当所有动画帧依次触发时,动画值会依次改变,所以构建的UI也会依次变化,所以最终我们可以看到一个完成的动画。 另外在动画的每一帧,Animation对象会调用其帧监听器,当动画状态发生改变时(如动画结束)会调用状态改变监听器。

注意: 在某些情况下,动画值可能会超出AnimationController[0.0,1.0]的范围,这取决于具体的曲线。例如,fling()函数可以根据我们手指滑动(甩出)的速度(velocity)、力量(force)等来模拟一个手指甩出动画,因此它的动画值可以在[0.0,1.0]范围之外 。也就是说,根据选择的曲线,CurvedAnimation的输出可以具有比输入更大的范围。例如,Curves.elasticIn等弹性曲线会生成大于或小于默认范围的值。

Ticker

当创建一个AnimationController时,需要传递一个vsync参数,它接收一个TickerProvider类型的对象,它的主要职责是创建Ticker,定义如下:

abstract class TickerProvider { 
  Ticker createTicker(TickerCallback onTick); // 通过一个回调创建一个Ticker
}

Flutter 应用在启动时都会绑定一个SchedulerBinding,通过SchedulerBinding可以给每一次屏幕刷新添加回调,而Ticker就是通过SchedulerBinding来添加屏幕刷新回调,这样一来,每次屏幕刷新都会调用TickerCallback

使用Ticker(而不是Timer)来驱动动画会防止屏幕外动画(动画的UI不在当前屏幕时,如锁屏时)消耗不必要的资源,因为Flutter中屏幕刷新时会通知到绑定的SchedulerBinding,而Ticker是受SchedulerBinding驱动的,由于锁屏后屏幕会停止刷新,所以Ticker就不会再触发。

通常我们会将SingleTickerProviderStateMixin类混入到自定义的State类中,然后将当前State对象作为AnimationControllervsync参数的值。

4. Tween

1)简介

默认情况下,AnimationController对象值的范围是[0.0,1.0]。如果我们需要构建UI的动画值在不同的范围或不同的数据类型,则可以使用Tween来添加映射以生成不同的范围或数据类型的值。

例如,像下面示例,Tween生成[-200.0,0.0]的值:

final Tween doubleTween = Tween<double>(begin: -200.0, end: 0.0);

Tween构造函数需要beginend两个参数。Tween的唯一职责就是定义从输入范围到输出范围的映射。输入范围通常为[0.0,1.0],但这不是必须的,我们可以自定义需要的范围。

Tween继承自Animatable,而不是继承自AnimationAnimatable中主要定义动画值的映射规则。

下面我们看一个ColorTween将动画输入范围映射为两种颜色值之间过渡输出的例子:

final Tween colorTween = ColorTween(begin: Colors.transparent, end: Colors.black54);

Tween对象不存储任何状态,相反,它提供了evaluate(Animation animation)方法,它可以获取动画当前映射值。 Animation对象的当前值可以通过value()方法取到。evaluate函数还执行一些其他处理,例如分别确保在动画值为0.01.0时返回开始和结束状态。

2)Tween.animate

要使用 Tween 对象,需要调用其animate()方法,然后传入一个AnimationController对象。

例如,以下代码在 500 毫秒内生成从 0255 的整数值。

final AnimationController controller = AnimationController(
  duration: const Duration(milliseconds: 500), 
  vsync: this,
);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);

注意animate()返回的是一个Animation,而不是一个Animatable

以下示例构建了一个控制器、一条曲线和一个 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);

线性插值 lerp 函数

动画的原理其实就是每一帧绘制不同的内容,一般都是指定起始和结束状态,然后在一段时间内从起始状态逐渐变为结束状态,而具体某一帧的状态值会根据动画的进度来算出,因此,Flutter 中给有可能会做动画的一些状态属性都定义了静态的 lerp 方法(线性插值),比如:

// a 为起始颜色,b为终止颜色,t为当前动画的进度[0,1]
Color.lerp(a, b, t);

lerp 的计算一般遵循: 返回值 = a + (b - a) * t,其他拥有 lerp 方法的类:

Size.lerp(a, b, t)
Rect.lerp(a, b, t)
Offset.lerp(a, b, t)
Decoration.lerp(a, b, t)
Tween.lerp(t) // 起始状态和终止状态在构建 Tween 的时候已经指定了
...

需要注意,lerp 是线性插值,意思是返回值和动画进度 t 是成一次函数(y = kx + b)关系,因为一次函数的图像是一条直线,所以叫线性插值。

如果我们想让动画按照一个曲线来执行,我们可以对 t 进行映射,比如要实现匀加速效果,则 t' = at²+bt+c,然后指定加速度 ab 即可(大多数情况下需保证 t' 的取值范围在[0,1],当然也有一些情况可能会超出该取值范围,比如弹簧(bounce)效果),而不同 Curve 可以按照不同曲线执行动画的的原理本质上就是对 t 按照不同映射公式进行映射实现的。

动画基本结构

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

1. 基础版本

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

class ScaleAnimationRoute extends StatefulWidget {
  const ScaleAnimationRoute({Key? key}) : super(key: key);
  
  _ScaleAnimationRouteState createState() => _ScaleAnimationRouteState();
}

// 需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationRouteState extends State<ScaleAnimationRoute> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;
  
  
  initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    // 匀速 图片宽高从0变到300
    animation = Tween(begin: 0.0, end: 300.0).animate(controller)
      ..addListener(() {
        setState(() => {});
      });
    // 启动动画(正向执行)
    controller.forward();
  }

  
  Widget build(BuildContext context) {
    return Center(
      child: Image.asset(
       "imgs/avatar.png",
        width: animation.value,
        height: animation.value,
      ),
    );
  }
  
  
  dispose() { 
    controller.dispose(); // 路由销毁时需要释放动画资源
    super.dispose();
  }
}

上面代码中addListener()函数调用了setState(),所以每次动画生成一个新的数字时,当前帧被标记为脏(dirty),这会导致widgetbuild()方法再次被调用,而在build()中,改变Image的宽高,因为它的高度和宽度现在使用的是animation.value ,所以就会逐渐放大。

值得注意的是动画完成时要释放控制器(调用dispose()方法)以防止内存泄漏。

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


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

运行效果:

Flutter 笔记 | Flutter 动画_第1张图片

2. 使用 AnimatedWidget 简化

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

import 'package:flutter/material.dart';

class AnimatedImage extends AnimatedWidget {
  const AnimatedImage({Key? key, required Animation<double> animation,}) : super(key: key, listenable: animation);

  
  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 ScaleAnimationRoute extends StatefulWidget {
  const ScaleAnimationRoute1({Key? key}) : super(key: key);
  
  _ScaleAnimationRouteState createState() =>  _ScaleAnimationRouteState();
}

class _ScaleAnimationRouteState extends State<ScaleAnimationRoute> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  
  initState() {
    super.initState();
    controller =  AnimationController(duration: const Duration(seconds: 2), vsync: this);
    // 图片宽高从0变到300
    animation =  Tween(begin: 0.0, end: 300.0).animate(controller);
    // 启动动画
    controller.forward();
  }

  
  Widget build(BuildContext context) {
    return AnimatedImage(animation: animation);
  }

  
  dispose() { 
    controller.dispose(); // 路由销毁时需要释放动画资源
    super.dispose();
  }
}

3. 用 AnimatedBuilder 重构

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


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,
          ),
        );
      },
    );
}

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

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

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

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

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

class GrowTransition extends StatelessWidget {
  const GrowTransition({Key? key, required this.animation, this.child,}) : super(key: key);

  final Widget? child;
  final Animation<double> animation;

  
  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,
      ),
    );
  }
}

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

...
Widget build(BuildContext context) {
  return GrowTransition(
    child: Image.asset("images/avatar.png"), 
    animation: animation,
  );
}

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

动画状态监听

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

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

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

initState() {
  super.initState();
  controller = AnimationController(
    duration: const Duration(seconds: 1), 
    vsync: this,
  );
  animation = Tween(begin: 0.0, end: 300.0).animate(controller); // 图片宽高从0变到300
  animation.addStatusListener((status) {
    if (status == AnimationStatus.completed) { 
      controller.reverse();  // 动画执行结束时反向执行动画
    } else if (status == AnimationStatus.dismissed) { 
      controller.forward(); // 动画恢复到初始状态时执行动画(正向)
    }
  }); 
  controller.forward();  // 启动动画(正向)
}

自定义路由切换动画

Material组件库中提供了一个MaterialPageRoute组件,它可以使用和平台风格一致的路由切换动画,如在iOS上会左右滑动切换,而在Android上会上下滑动切换。现在,我们如果在Android上也想使用左右切换风格,该怎么做?一个简单的作法是可以直接使用CupertinoPageRoute,如:

 Navigator.push(context, CupertinoPageRoute(  
   builder: (context)=>PageB(),
 ));

CupertinoPageRouteCupertino组件库提供的iOS风格的路由切换组件,它实现的就是左右滑动切换。那么我们如何来自定义路由切换动画呢?答案就是PageRouteBuilder

下面我们来看看如何使用PageRouteBuilder来自定义路由切换动画。例如我们想以渐隐渐入动画来实现路由过渡,实现代码如下:

Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: Duration(milliseconds: 500), // 动画时间为500毫秒
    pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) {
      return FadeTransition( // 使用渐隐渐入过渡
        opacity: animation,
        child: PageB(), // 路由B
      );
    },
  ),
);

我们可以看到pageBuilder 有一个animation参数,这是Flutter路由管理器提供的,在路由切换时pageBuilder在每个动画帧都会被回调,因此我们可以通过animation对象来自定义过渡动画。

无论是MaterialPageRouteCupertinoPageRoute,还是PageRouteBuilder,它们都继承自PageRoute类,而PageRouteBuilder其实只是PageRoute的一个包装,我们可以直接继承PageRoute类来实现自定义路由,上面的例子可以通过如下方式实现:

  1. 定义一个路由类FadeRoute
class FadeRoute extends PageRoute {
  FadeRoute({
    required this.builder,
    this.transitionDuration = const Duration(milliseconds: 300),
    this.opaque = true,
    this.barrierDismissible = false,
    this.barrierColor,
    this.barrierLabel,
    this.maintainState = true,
  });

  final WidgetBuilder builder;

  
  final Duration transitionDuration;

  
  final bool opaque;

  
  final bool barrierDismissible;

  
  final Color barrierColor;

  
  final String barrierLabel;

  
  final bool maintainState;

  
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) => builder(context);

  
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
     return FadeTransition( 
       opacity: animation,
       child: builder(context),
     );
  }
}
  1. 使用FadeRoute
Navigator.push(context, FadeRoute(builder: (context) {
  return PageB();
}));

虽然上面的两种方法都可以实现自定义切换动画,但实际使用时应优先考虑使用PageRouteBuilder,这样无需定义一个新的路由类,使用起来会比较方便。

但是有些时候PageRouteBuilder是不能满足需求的,例如在应用过渡动画时我们需要读取当前路由的一些属性,这时就只能通过继承PageRoute的方式了,举个例子,假如我们只想在打开新路由时应用动画,而在返回时不使用动画,那么我们在构建过渡动画时就必须判断当前路由isActive属性是否为true,代码如下:


Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) { 
	 if (isActive) {  // 当前路由被激活,是打开新路由
	   return FadeTransition(
	     opacity: animation,
	     child: builder(context),
	   );
	 } else { // 是返回,则不应用过渡动画
	   return Padding(padding: EdgeInsets.zero);
	 }
}

关于路由参数的详细信息可自行查阅API文档,比较简单,不再赘述。

Hero 动画

Hero 指的是可以在路由(页面)之间“飞行”的 widget,简单来说 Hero 动画就是在路由切换时,有一个共享的 widget 可以在新旧路由间切换。由于共享的 widget 在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会从旧路逐渐过渡到新路由中的指定位置,这样就会产生一个 Hero 动画。

你可能多次看到过 hero 动画。例如,一个路由中显示待售商品的缩略图列表,选择一个条目会将其跳转到一个新路由,新路由中包含该商品的详细信息和“购买”按钮。 在Flutter中将图片从一个路由“飞”到另一个路由称为 hero动画,尽管相同的动作有时也称为 共享元素转换。下面我们通过一个示例来体验一下 hero 动画。

示例

假设有两个路由 A 和 B,他们的内容交互如下:

A:包含一个用户头像,圆形,点击后跳到 B 路由,可以查看大图。

B:显示用户头像原图,矩形。

在 A B 两个路由之间跳转的时候,用户头像会逐渐过渡到目标路由页的头像上,接下来我们先看看代码,然后再解析。

路由 A:

class HeroAnimationRouteA extends StatelessWidget {
  const HeroAnimationRouteA({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Hero动画"),),
      body: Container(
        alignment: Alignment.topCenter,
        child: InkWell(
          child: Hero(
            tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
            child: ClipOval(
              child: Image.asset("images/avatar.png", width: 100.0,),
            ),
          ),
          onTap: () {
            // 打开B路由
            Navigator.push(context, PageRouteBuilder(
              transitionDuration: const Duration(milliseconds: 500),
              pageBuilder: (context, animation, secondaryAnimation) {
                return FadeTransition(
                  opacity: animation,
                  child: const HeroAnimationRouteB(),
                );
              },
            ));
          },
        ),
      ),
    );
  }
}

路由 B:

class HeroAnimationRouteB extends StatelessWidget {
  const HeroAnimationRouteB({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return Scaffold(
     //appBar: AppBar(title: const Text("原图"),),
      body: Center(
        child: Hero(
          tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
          child: Image.asset("images/avatar.png"),
        ),
      ),
    );
  }
}

效果:

Flutter 笔记 | Flutter 动画_第2张图片

我们可以看到,实现 Hero 动画只需要用Hero组件将要共享的 widget 包装起来,并提供一个相同的 tag 即可,中间的过渡帧都是 Flutter 框架自动完成的。必须要注意, 前后路由页的共享Herotag 必须是相同的,Flutter 框架内部正是通过 tag 来确定新旧路由页widget的对应关系的。

Hero 动画的原理比较简单,Flutter 框架知道新旧路由页中共享元素的位置和大小,所以根据这两个端点,在动画执行过程中求出过渡时的插值(中间态)即可,而感到幸运的是,这些事情不需要我们自己动手,Flutter 已经帮我们做了,如有兴趣可以去看 Hero 动画相关的源码。

交织动画

有些时候我们可能会需要一些复杂的动画,这些动画可能由一个动画序列或重叠的动画组成,比如:有一个柱状图,需要在高度增长的同时改变颜色,等到增长到最大高度后,我们需要在X轴上平移一段距离。可以发现上述场景在不同阶段包含了多种动画,要实现这种效果,使用交织动画(Stagger Animation)会非常简单。交织动画需要注意以下几点:

  1. 要创建交织动画,需要使用多个动画对象(Animation)。
  2. 一个AnimationController控制所有的动画对象。
  3. 给每一个动画对象指定时间间隔(Interval

所有动画都由同一个AnimationController 驱动,无论动画需要持续多长时间,控制器的值必须在0.01.0之间,而每个动画的间隔(Interval)也必须介于0.01.0之间。对于在间隔中设置动画的每个属性,需要分别创建一个Tween 用于指定该属性的开始值和结束值。也就是说0.01.0代表整个动画过程,我们可以给不同动画指定不同的起始点和终止点来决定它们的开始时间和终止时间。

下面我们看一个例子,实现一个柱状图增长的动画:

  1. 开始时高度从0增长到300像素,同时颜色由绿色渐变为红色;这个过程占据整个动画时间的60%。
  2. 高度增长到300后,开始沿X轴向右平移100像素;这个过程占用整个动画时间的40%。

我们将执行动画的Widget分离出来:

class StaggerAnimation extends StatelessWidget {

  StaggerAnimation({ Key? key, required this.controller }): super(key: key){

    var animationFirst = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.0, 0.6, curve: Curves.ease,),//间隔,前60%的动画时间
    );

    var animationAfter = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.6, 1.0, curve: Curves.ease,), //间隔,后40%的动画时间
    );

    // 高度动画
    height = Tween<double>(begin:.0, end: 300.0,).animate(animationFirst);
    // 颜色
    color = ColorTween(begin:Colors.green, end:Colors.red,).animate(animationFirst);
    // 边距
    padding = Tween<EdgeInsets>(
      begin: const EdgeInsets.only(left: .0),
      end: const EdgeInsets.only(left: 100.0),).animate(animationAfter);
  }

  final Animation<double> controller;
  late final Animation<double> height;
  late final Animation<EdgeInsets> padding;
  late final Animation<Color?> color;

  Widget _buildAnimation(BuildContext context, Widget? child) {
    return Container(
      alignment: Alignment.bottomCenter,
      padding:padding.value ,
      child: Container(
        color: color.value,
        width: 50.0,
        height: height.value,
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      builder: _buildAnimation,
      animation: controller,
    );
  }
}

StaggerAnimation中定义了三个动画,分别是对Containerheightcolorpadding属性设置的动画,然后通过Interval来为每个动画指定在整个动画过程中的起始点和终点。下面我们来实现启动动画的路由:

class StaggerRoute extends StatefulWidget {
  const StaggerRoute({Key? key}) : super(key: key);

  
  State createState() => _StaggerRouteState();
}

class _StaggerRouteState extends State<StaggerRoute> with TickerProviderStateMixin {
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this);
  }

  Future<void> _playAnimation() async {
    try {
      //先正向执行动画
      await _controller.forward().orCancel;
      //再反向执行动画
      await _controller.reverse().orCancel;
    } on TickerCanceled {
      // the animation got canceled, probably because we were disposed
    }
  }

  
  Widget build(BuildContext context) {
    return  Scaffold(
      appBar: AppBar(title: const Text("StaggerAnimation"),),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () => _playAnimation(),
        child: Center(
          child: Container(
            width: 300.0,
            height: 300.0,
            decoration: BoxDecoration(
              color: Colors.black.withOpacity(0.1),
              border: Border.all(color: Colors.black.withOpacity(0.5),),
            ),
            //调用我们定义的交织动画Widget
            child: StaggerAnimation(controller: _controller),
          ),
        ),
      ),
    );
  }

  
  void dispose() {
    //路由销毁时需要释放动画资源
    _controller.dispose();
    super.dispose();
  }
}

执行效果:

Flutter 笔记 | Flutter 动画_第3张图片

动画切换组件

AnimatedSwitcher

实际开发中,我们经常会遇到切换UI元素的场景,比如Tab切换、路由切换。为了增强用户体验,通常在切换时都会指定一个动画,以使切换过程显得平滑。Flutter SDK组件库中已经提供了一些常用的切换组件,如PageViewTabView等,但是,这些组件并不能覆盖全部的需求场景,为此,Flutter SDK中提供了一个AnimatedSwitcher组件,它定义了一种通用的UI切换抽象。

AnimatedSwitcher 可以同时对其新、旧子元素添加显示、隐藏动画。也就是说在AnimatedSwitcher的子元素发生变化时,会对其旧元素和新元素做动画,我们先看看AnimatedSwitcher 的定义:

const AnimatedSwitcher({
  Key? key,
  this.child,
  required this.duration, // 新child显示动画时长
  this.reverseDuration,// 旧child隐藏的动画时长
  this.switchInCurve = Curves.linear, // 新child显示的动画曲线
  this.switchOutCurve = Curves.linear,// 旧child隐藏的动画曲线
  this.transitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, // 动画构建器
  this.layoutBuilder = AnimatedSwitcher.defaultLayoutBuilder, //布局构建器
})

AnimatedSwitcherchild 发生变化时(类型或 Key 不同),旧 child 会执行隐藏动画,新 child 会执行执行显示动画。究竟执行何种动画效果则由transitionBuilder参数决定,该参数接受一个AnimatedSwitcherTransitionBuilder类型的 builder,定义如下:

typedef AnimatedSwitcherTransitionBuilder = Widget Function(Widget child, Animation<double> animation);

builderAnimatedSwitcherchild切换时会分别对新、旧child绑定动画:

  1. 对旧child,绑定的动画会反向执行(reverse
  2. 对新child,绑定的动画会正向指向(forward

这样一下,便实现了对新、旧child的动画绑定。AnimatedSwitcher的默认值是AnimatedSwitcher.defaultTransitionBuilder

Widget defaultTransitionBuilder(Widget child, Animation<double> animation) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
}

可以看到,返回了FadeTransition对象,也就是说默认情况,AnimatedSwitcher会对新旧child执行“渐隐”和“渐显”动画。

示例

下面我们看一个例子:实现一个计数器,然后在每一次自增的过程中,旧数字执行缩小动画隐藏,新数字执行放大动画显示,代码如下:

import 'package:flutter/material.dart';

class AnimatedSwitcherCounterRoute extends StatefulWidget {
   const AnimatedSwitcherCounterRoute({Key key}) : super(key: key); 
   
   State createState() => _AnimatedSwitcherCounterRouteState();
 }

 class _AnimatedSwitcherCounterRouteState extends State<AnimatedSwitcherCounterRoute> {
   int _count = 0;

   
   Widget build(BuildContext context) {
     return Center(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           AnimatedSwitcher(
             duration: const Duration(milliseconds: 500),
             transitionBuilder: (Widget child, Animation<double> animation) { 
               return ScaleTransition(child: child, scale: animation); // 执行缩放动画
             },
             child: Text(
               '$_count',
               // 显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
               key: ValueKey<int>(_count),
               style: Theme.of(context).textTheme.headline4,
             ),
           ),
           ElevatedButton(
             child: const Text('+1',),
             onPressed: () {
               setState(() {
                 _count += 1;
               });
             },
           ),
         ],
       ),
     );
   }
 }

运行示例代码,当点击“+1”按钮时,原先的数字会逐渐缩小直至隐藏,而新数字会逐渐放大,如图所示:

Flutter 笔记 | Flutter 动画_第4张图片

注意:AnimatedSwitcher的新旧child,如果类型相同,则Key必须不相等。

AnimatedSwitcher实现原理

实际上,AnimatedSwitcher的实现原理是比较简单的,我们根据AnimatedSwitcher的使用方式也可以猜个大概。要想实现新旧 child 切换动画,只需要明确两个问题:

  1. 动画执行的时机是什么时候?
  2. 如何对新旧child执行动画?

AnimatedSwitcher的使用方式我们可以看到:当child发生变化时(子 widgetkey 或类型不同时则认为发生变化),则重新会重新执行build,然后动画开始执行。

我们可以通过继承 StatefulWidget 来实现AnimatedSwitcher,具体做法是在didUpdateWidget 回调中判断其新旧 child 是否发生变化,如果发生变化,则对旧 child 执行反向退场(reverse)动画,对新child执行正向(forward)入场动画即可。下面是AnimatedSwitcher实现的部分核心伪代码:

Widget _widget; 
void didUpdateWidget(AnimatedSwitcher oldWidget) {
  super.didUpdateWidget(oldWidget);
  // 检查新旧child是否发生变化(key和类型同时相等则返回true,认为没变化)
  if (Widget.canUpdate(widget.child, oldWidget.child)) {
    // child没变化,...
  } else {
    //child发生了变化,构建一个Stack来分别给新旧child执行动画
   _widget= Stack(
      alignment: Alignment.center,
      children:[
        //旧child应用FadeTransition
        FadeTransition(
         opacity: _controllerOldAnimation,
         child : oldWidget.child,
        ),
        //新child应用FadeTransition
        FadeTransition(
         opacity: _controllerNewAnimation,
         child : widget.child,
        ),
      ]
    );
    // 给旧child执行反向退场动画
    _controllerOldAnimation.reverse();
    //给新child执行正向入场动画
    _controllerNewAnimation.forward();
  }
}

//build方法
Widget build(BuildContext context){
  return _widget;
}

上面伪代码展示了AnimatedSwitcher实现的核心逻辑,当然AnimatedSwitcher真正的实现比这个复杂,它可以自定义进退场过渡动画以及执行动画时的布局等。在此,我们删繁就简,通过伪代码形式能够清楚看到主要的实现思路,具体的实现可以参考AnimatedSwitcher源码。

另外,Flutter SDK中还提供了一个AnimatedCrossFade组件,它也可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedSwitcher是在一个子元素的新旧值之间切换AnimatedCrossFade实现原理也比较简单,和AnimatedSwitcher类似,因此不再赘述,如有兴趣可以查看其源码。

AnimatedSwitcher高级用法

假设现在我们想实现一个类似路由平移切换的动画:旧页面屏幕中向左侧平移退出,新页面从屏幕右侧平移进入。如果要用AnimatedSwitcher的话,我们很快就会发现一个问题:做不到!我们可能会写出下面的代码:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
     return SlideTransition(
       child: child,
       position: tween.animate(animation),
    );
  },
  ...//省略
)

上面的代码有什么问题呢?我们前面说过在AnimatedSwitcherchild 切换时会对新child执行正向动画(forward),而对旧child执行反向动画(reverse),所以真正的效果便是:新 child 确实从屏幕右侧平移进入了,但旧child却会从屏幕右侧(而不是左侧)退出。其实也很容易理解,因为在没有特殊处理的情况下,同一个动画的正向和逆向正好是相反(对称)的。

那么问题来了,难道就不能使用AnimatedSwitcher了?答案当然是否定的!仔细想想这个问题,究其原因,就是因为同一个Animation正向(forward)和反向(reverse)是对称的。所以如果我们可以打破这种对称性,那么便可以实现这个功能了,下面我们来封装一个MySlideTransition,它与SlideTransition唯一的不同就是对动画的反向执行进行了定制(从左边滑出隐藏),代码如下:

class MySlideTransition extends AnimatedWidget {
  const MySlideTransition({
    Key? key,
    required Animation<Offset> position,
    this.transformHitTests = true,
    required this.child,
  }) : super(key: key, listenable: position);

  final bool transformHitTests;

  final Widget child;

  
  Widget build(BuildContext context) {
    final position = listenable as Animation<Offset>;
    Offset offset = position.value;
    if (position.status == AnimationStatus.reverse) {
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

调用时,将SlideTransition替换成MySlideTransition即可:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
     return MySlideTransition(
      child: child,
      position: tween.animate(animation),
    );
  },
  ...//省略
)

效果:

Flutter 笔记 | Flutter 动画_第5张图片

可以看到,我们通过这种巧妙的方式实现了类似路由进场切换的动画,实际上Flutter路由切换也正是通过AnimatedSwitcher来实现的。

SlideTransitionX

上面的示例我们实现了“左出右入”的动画,那如果要实现“左入右出”、“上入下出”或者 “下入上出”怎么办?当然,我们可以分别修改上面的代码,但是这样每种动画都得单独定义一个“Transition”,这很麻烦。

下面将封装一个通用的SlideTransitionX 来实现这种“出入动画”,代码如下:

import 'package:flutter/widgets.dart';

/// 实现同向滑动效果,通常和[AnimatedSwitcher]一起使用
/// Animates the position of a widget relative to its normal position
/// ignoring the animation direction(always slide along one direction).
/// Typically, is used in combination with [AnimatedSwitcher].
class SlideTransitionX extends AnimatedWidget {
  SlideTransitionX({
    Key? key,
    required Animation<double> position,
    this.transformHitTests = true,
    this.direction = AxisDirection.down,
    required this.child,
  }) : super(key: key, listenable: position) {
    _tween = Tween(begin: const Offset(0, 1), end: Offset.zero);
    // 偏移在内部处理
    switch (direction) {
      case AxisDirection.up:
        _tween.begin = const Offset(0, 1);
        break;
      case AxisDirection.right:
        _tween.begin = const Offset(-1, 0);
        break;
      case AxisDirection.down:
        _tween.begin = const Offset(0, -1);
        break;
      case AxisDirection.left:
        _tween.begin = const Offset(1, 0);
        break;
    }
  }

  final bool transformHitTests;

  final Widget child;

  //退场(出)方向
  final AxisDirection direction;

  late final Tween<Offset> _tween;

  
  Widget build(BuildContext context) {
    final position = listenable as Animation<double>;
    Offset offset = _tween.evaluate(position);
    //执行反向动画时 再反一下处理
    if (position.status == AnimationStatus.reverse) {
      switch (direction) {
        case AxisDirection.up:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.right:
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.down:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.left:
          offset = Offset(-offset.dx, offset.dy);
          break;
      }
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

现在如果我们想实现各种“滑动出入动画”便非常容易,只需给direction传递不同的方向值即可,比如要实现“上入下出”,则:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
     return SlideTransitionX(
       child: child,
       direction: AxisDirection.down, //上入下出
       position: animation,
     );
  },
  ...//省略其余代码
)

效果:

Flutter 笔记 | Flutter 动画_第6张图片

可以尝试给SlideTransitionXdirection取不同的值来查看运行效果。

动画过渡组件

我们将在Widget属性发生变化时会执行过渡动画的组件统称为”动画过渡组件“,而动画过渡组件最明显的一个特征就是它会在内部自管理AnimationController。我们知道,为了方便使用者可以自定义动画的曲线、执行时长、方向等,在前面介绍过的动画封装方法中,通常都需要使用者自己提供一个AnimationController对象来自定义这些属性值。但是,如此一来,使用者就必须得手动管理AnimationController,这又会增加使用的复杂性。因此,如果也能将AnimationController进行封装,则会大大提高动画组件的易用性。

自定义动画过渡组件

我们要实现一个AnimatedDecoratedBox,它可以在decoration属性发生变化时,从旧状态变成新状态的过程可以执行一个过渡动画。根据前面所学的知识,我们实现了一个AnimatedDecoratedBox1组件:

class AnimatedDecoratedBox extends StatefulWidget {
  const AnimatedDecoratedBox({
    Key? key,
    required this.decoration,
    required this.child,
    this.curve = Curves.linear,
    required this.duration,
    this.reverseDuration,
  }) : super(key: key);

  final BoxDecoration decoration;
  final Widget child;
  final Duration duration;
  final Curve curve;
  final Duration? reverseDuration;

  
  State createState() => _AnimatedDecoratedBoxState();
}

class _AnimatedDecoratedBoxState extends State<AnimatedDecoratedBox> with SingleTickerProviderStateMixin {
  
  AnimationController get controller => _controller;
  late AnimationController _controller;

  Animation<double> get animation => _animation;
  late Animation<double> _animation;

  late DecorationTween _tween;

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return DecoratedBox(
          decoration: _tween.animate(_animation).value,
          child: child,
        );
      },
      child: widget.child,
    );
  }

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: widget.duration,
      reverseDuration: widget.reverseDuration,
      vsync: this,
    );
    _tween = DecorationTween(begin: widget.decoration);
    _updateCurve();
  }

  void _updateCurve() {
    _animation = CurvedAnimation(parent: _controller, curve: widget.curve);
  }

  
  void didUpdateWidget(AnimatedDecoratedBox1 oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.curve != oldWidget.curve) _updateCurve();
    _controller.duration = widget.duration;
    _controller.reverseDuration = widget.reverseDuration;
    //正在执行过渡动画
    if (widget.decoration != (_tween.end ?? _tween.begin)) {
      _tween
        ..begin = _tween.evaluate(_animation)
        ..end = widget.decoration;

      _controller
        ..value = 0.0
        ..forward();
    }
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

下面我们来使用AnimatedDecoratedBox来实现按钮点击后背景色从蓝色过渡到红色的效果:

class AnimatedDecoratedBoxExample extends StatefulWidget {
  const AnimatedDecoratedBoxExample({Key? key}) : super(key: key);
  
  State createState() => _AnimatedDecoratedBoxExampleState();
}
 
class _AnimatedDecoratedBoxExampleState
    extends State<AnimatedDecoratedBoxExample> {
  Color _decorationColor = Colors.blue;
  var duration = const Duration(seconds: 1);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("自定义动画过渡组件"),
      ),
      body: Center(
        child: AnimatedDecoratedBox(
          duration: duration,
          decoration: BoxDecoration(color: _decorationColor),
          child: TextButton(
            onPressed: () {
              setState(() {
                _decorationColor = (_decorationColor == Colors.blue
                    ? Colors.red
                    : Colors.blue);
              });
            },
            child: const Text(
              "AnimatedDecoratedBox",
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
      ),
    );
  }
}

效果:

Flutter 笔记 | Flutter 动画_第7张图片

上面的代码虽然实现了我们期望的功能,但是代码却比较复杂。稍加思考后,我们就可以发现,AnimationController的管理以及Tween更新部分的代码都是可以抽象出来的,如果我们这些通用逻辑封装成基类,那么要实现动画过渡组件只需要继承这些基类,然后定制自身不同的代码(比如动画每一帧的构建方法)即可,这样将会简化代码。

为了方便开发者来实现动画过渡组件的封装,Flutter提供了一个ImplicitlyAnimatedWidget抽象类,它继承自StatefulWidget,同时提供了一个对应的ImplicitlyAnimatedWidgetState类,AnimationController的管理就在ImplicitlyAnimatedWidgetState类中。开发者如果要封装动画,只需要分别继承ImplicitlyAnimatedWidgetImplicitlyAnimatedWidgetState类即可,下面我们演示一下具体如何实现。

我们需要分两步实现:

  1. 继承ImplicitlyAnimatedWidget类。
class AnimatedDecoratedBox extends ImplicitlyAnimatedWidget {
  const AnimatedDecoratedBox({
    Key? key,
    required this.decoration,
    required this.child,
    Curve curve = Curves.easeIn, //动画曲线
    required Duration duration, // 正向动画执行时长
  }) : super(
          key: key,
          curve: curve,
          duration: duration,
        );
  final BoxDecoration decoration;
  final Widget child;

  
  ImplicitlyAnimatedWidgetState createState() => _AnimatedDecoratedBoxState();
}

其中curve、duration、reverseDuration三个属性在ImplicitlyAnimatedWidget中已定义。 可以看到AnimatedDecoratedBox类和普通继承自StatefulWidget的类没有什么不同。

  1. State类继承AnimatedWidgetBaseState(该类继承自ImplicitlyAnimatedWidgetState类)。
class _AnimatedDecoratedBoxState
    extends AnimatedWidgetBaseState<AnimatedDecoratedBox> {
  Tween<dynamic>? _decoration; //定义一个Tween

  
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: _decoration?.evaluate(animation),
      child: widget.child,
    );
  }
 
  
  void forEachTween(TweenVisitor<dynamic> visitor) {
    // 在需要更新Tween时,基类会调用此方法
    _decoration = visitor(_decoration, widget.decoration,
        (value) => DecorationTween(begin: value));
  }
}

可以看到我们实现了buildforEachTween两个方法。在动画执行过程中,每一帧都会调用build方法(调用逻辑在ImplicitlyAnimatedWidgetState中),所以在build方法中我们需要构建每一帧的DecoratedBox状态,因此得算出每一帧的decoration 状态,这个我们可以通过_decoration.evaluate(animation) 来算出,其中animationImplicitlyAnimatedWidgetState基类中定义的对象,_decoration是我们自定义的一个DecorationTween类型的对象,那么现在的问题就是它是在什么时候被赋值的呢?

要回答这个问题,我们就得搞清楚什么时候需要对_decoration赋值。我们知道_decoration是一个Tween,而Tween的主要职责就是定义动画的起始状态(begin)和终止状态(end)。对于AnimatedDecoratedBox来说,decoration的终止状态就是用户传给它的值,而起始状态是不确定的,有以下两种情况:

  1. AnimatedDecoratedBox首次build,此时直接将其decoration值置为起始状态,即_decoration值为DecorationTween(begin: decoration)
  2. AnimatedDecoratedBoxdecoration更新时,则起始状态为_decoration.animate(animation),即_decoration值为DecorationTween(begin: _decoration.animate(animation),end:decoration)

现在forEachTween的作用就很明显了,它正是用于来更新Tween的初始值的,在上述两种情况下会被调用,而开发者只需重写此方法,并在此方法中更新Tween的起始状态值即可。而一些更新的逻辑被屏蔽在了visitor回调,我们只需要调用它并给它传递正确的参数即可,visitor方法签名如下:

 Tween<T> visitor(
   Tween<T> tween, //当前的tween,第一次调用为null
   T targetValue, // 终止状态
   TweenConstructor<T> constructor,//Tween构造器,在上述三种情况下会被调用以更新tween
 );

可以看到,通过继承ImplicitlyAnimatedWidgetImplicitlyAnimatedWidgetState类可以快速的实现动画过渡组件的封装,这和我们纯手工实现相比,代码简化了很多。

Flutter 预置的动画过渡组件

Flutter SDK中也预置了很多动画过渡组件,实现方式和大都和AnimatedDecoratedBox差不多,如下表所示:

组件名 功能
AnimatedPadding padding发生变化时会执行过渡动画到新状态
AnimatedPositioned 配合Stack一起使用,当定位状态发生变化时会执行过渡动画到新的状态
AnimatedOpacity 在透明度opacity发生变化时执行过渡动画到新状态
AnimatedAlign alignment发生变化时会执行过渡动画到新的状态
AnimatedContainer Container属性发生变化时会执行过渡动画到新的状态
AnimatedDefaultTextStyle 当字体样式发生变化时,子组件中继承了该样式的文本组件会动态过渡到新样式

下面我们通过一个示例来感受一下这些预置的动画过渡组件效果:

import 'package:flutter/material.dart';

class AnimatedWidgetsTest extends StatefulWidget {
  const AnimatedWidgetsTest({Key? key}) : super(key: key);

  
  State createState() => _AnimatedWidgetsTestState();
}

class _AnimatedWidgetsTestState extends State<AnimatedWidgetsTest> {
  double _padding = 10;
  var _align = Alignment.topRight;
  double _height = 100;
  double _left = 0;
  Color _color = Colors.red;
  TextStyle _style = const TextStyle(color: Colors.black);
  Color _decorationColor = Colors.blue;

  
  Widget build(BuildContext context) {
    var duration = const Duration(seconds: 1);
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          ElevatedButton(
            onPressed: () => setState(() => _padding = 20),
            child: AnimatedPadding(
              duration: duration,
              padding: EdgeInsets.all(_padding),
              child: const Text("AnimatedPadding"),
            ),
          ),
          SizedBox(
            height: 50,
            child: Stack(
              children: <Widget>[
                AnimatedPositioned(
                  duration: duration,
                  left: _left,
                  child: ElevatedButton(
                    onPressed: () => setState(() => _left = 100),
                    child: const Text("AnimatedPositioned"),
                  ),
                )
              ],
            ),
          ),
          Container(
            height: 100,
            color: Colors.grey,
            child: AnimatedAlign(
              duration: duration,
              alignment: _align,
              child: ElevatedButton(
                onPressed: () => setState(() => _align = Alignment.center),
                child: const Text("AnimatedAlign"),
              ),
            ),
          ),
          AnimatedContainer(
            duration: duration,
            height: _height,
            color: _color,
            child: TextButton(
              onPressed: () {
                setState(() {
                  _height = 150;
                  _color = Colors.blue;
                });
              },
              child: const Text(
                "AnimatedContainer",
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
          AnimatedDefaultTextStyle(
            style: _style,
            duration: duration,
            child: GestureDetector(
              child: const Text("hello world"),
              onTap: () {
                setState(() {
                  _style = const TextStyle(
                    color: Colors.blue,
                    decorationStyle: TextDecorationStyle.solid,
                    decorationColor: Colors.blue,
                  );
                });
              },
            ),
          ),
          AnimatedDecoratedBox(
            duration: duration,
            decoration: BoxDecoration(color: _decorationColor),
            child: TextButton(
              onPressed: () => setState(() => _decorationColor = Colors.red),
              child: const Text(
                "AnimatedDecoratedBox",
                style: TextStyle(color: Colors.white),
              ),
            ),
          )
        ].map((e) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 16),
            child: e,
          );
        }).toList(),
      ),
    );
  }
}

运行效果:

Animation 源码分析

Animation的关键类及其关系如图8-5所示。

Flutter 笔记 | Flutter 动画_第8张图片

图8-5中,Animation是动画的关键类,它继承自ListenableAnimation持有一个动画值(value)和一个动画状态(status)提供给外部进行监听,外部使用者将根据这些监听回调进行UI的更新等操作。

AnimationControllerAnimation最常见的实现,它持有两个关键字段——TickerSimulation,前者由TickerProvider接口提供,用于提供驱动动画更新的“心跳”,主要用于补间动画;后者提供动画值的更新规则,由具体子类实现。

Simulation的默认实现为_InterpolationSimulation,它将基于Curve的具体子类进行值的计算。此外,Simulation还提供了各种物理效果的模拟能力,例如SpringSimulation提供了弹簧效果的模拟。

一般来说,AnimationControllervalue字段并不会直接被使用,_AnimatedEvaluation会持有一个Animation对象(通常是AnimationController的实例)和一个Animatable对象,前者提供动画的原始值,后者以前者的动画值(value)为参数进行“补间”(Tween),最终的值将通过_AnimatedEvaluation实例的value字段对外暴露。

下面从源码的角度进行更具体的分析。

补间动画

补间(Tween)动画只在指定的时间范围内按照某一规则进行插值,其使用流程通常如代码清单8-28所示。

// 代码清单8-28 补间动画示例
AnimationController animationController = // 见代码清单8-29
         AnimationController(vsync: this, duration: Duration(milliseconds: 1000));
Animation animation =  // 见代码清单8-30
         Tween(begin: 0.0,end: 10.0).animate(animationController);
animationController.addListener(() { // 通知发生,见代码清单8-40
 setState(() { newValue = animation.value });
});
animationController.forward(); // 见代码清单8-32

以上逻辑中,AnimationController是驱动者,Tween提供补间动画的插值模型,Animation 作为最终的调用出口。下面通过代码深入分析。

首先分析AnimationController的初始化逻辑,如代码清单8-29所示。

// 代码清单8-29 flutter/packages/flutter/lib/src/animation/animation_controller.dart
AnimationController({  ......, required TickerProvider vsync,
    }) : _direction = _AnimationDirection.forward {
  _ticker = vsync.createTicker(_tick); // _tick将在"心跳"时触发,见代码清单8-38
  _internalSetValue(value ?? lowerBound); // 更新当前动画的状态
}
void _internalSetValue(double newValue) {
  _value = newValue.clamp(lowerBound, upperBound);
  if (_value == lowerBound) {
    _status = AnimationStatus.dismissed; // 动画尚未开始
  } else if (_value == upperBound) {
    _status = AnimationStatus.completed; // 已经结束
  } else {
    _status = (_direction == _AnimationDirection.forward) ? // 动画进行中
            AnimationStatus.forward : AnimationStatus.reverse;
  }
}  

AnimationController的初始化逻辑中,首先创建一个Ticker对象,它是补间动画的核心驱动者,这部分内容后面将详细分析。_internalSetValue负责更新当前动画的状态,在此不再赘述。

代码清单8-28中,每一帧动画更新时都会通过animation.value获取当前的值,其计算过程如代码清单8-30所示。

// 代码清单8-30 flutter/packages/flutter/lib/src/animation/tween.dart 
abstract class Animatable<T> {
  T transform(double t); // 见代码清单8-31
  T evaluate(Animation<double> animation) => transform(animation.value);
  Animation<T> animate(Animation<double> parent) {
    return _AnimatedEvaluation<T>(parent, this);
  }
} // Animatable
class _AnimatedEvaluation<T> extends Animation<T>
             with AnimationWithParentMixin<double> {
  _AnimatedEvaluation(this.parent, this._evaluatable);
  
  final Animation<double> parent; // 通常为AnimationController 
  final Animatable<T> _evaluatable; // 见代码清单8-31
    // 代码清单8-28中获取的值
  T get value => _evaluatable.evaluate(parent);  // 见前面内容
}

TweenAnimatable的子类,其animate方法由父类实现,主要返回_AnimatedEvaluation 对象,所以调用animation.value时,本质是调用具体插值模型,即Animatable的具体子类(如Tween)的evaluate方法,而evaluate其实又调用了transform方法,以Tween为例,其逻辑如代码清单8-31所示。

// 代码清单8-31 flutter/packages/flutter/lib/src/animation/tween.dart
class Tween<T extends dynamic> extends Animatable<T> {
  Tween({this.begin, this.end,});
  
  T lerp(double t) { // Linear Interpolation
    return begin + (end - begin) * t as T;
  }
  
  T transform(double t) { // t 即AnimationController的值,在代码清单8-33中进行定义
    if (t == 0.0) return begin as T;
    if (t == 1.0) return end as T;
    return lerp(t); // 线性差值的逻辑
  }
}

以上逻辑是一个典型的线性插值过程,其参数为AnimationController.value,该值在每一帧都会更新,而Tween则会基于这个值完成线性插值,其结果就是_AnimatedEvaluation对象的值。

下面分析AnimationController.value更新的驱动机制,也就是forward方法,如代码清单8-32所示。

// 代码清单8-32 flutter/packages/flutter/lib/src/animation/animation_controller.dart
TickerFuture forward({ double? from }) { // AnimationController
  _direction = _AnimationDirection.forward;
  if (from != null) value = from;
  return _animateToInternal(upperBound);
}
TickerFuture _animateToInternal(double target, // 即upperBound
    { Duration? duration, Curve curve = Curves.linear }) {
  double scale = 1.0;
  if (SemanticsBinding.instance!.disableAnimations) { ...... } // SKIP 禁用动画的逻辑
  Duration? simulationDuration = duration; // 第1步,计算动画的执行时长
  if (simulationDuration == null) { // 没有指定动画时长
    final double range = upperBound - lowerBound; // 根据当前进度,以1s为基准计算
    final double remainingFraction = range.isFinite ? (target - _value).abs() / 
       range : 1.0;
    final Duration directionDuration = // 根据动画是顺序还是逆序来计算最终的执行时长
      (_direction == _AnimationDirection.reverse && reverseDuration != null)
      ? reverseDuration! : this.duration!;
    simulationDuration = directionDuration * remainingFraction;
  } else if (target == value) { // 动画已完成,对应第2步
    simulationDuration = Duration.zero;
  }
  stop(); // 见代码清单8-41
  if (simulationDuration == Duration.zero) { // 第2步,动画结束,完成字段更新,并通知
    if (value != target) {
      _value = target.clamp(lowerBound, upperBound); // 修正值到合法的目标值
      notifyListeners(); // 通知值变化,见代码清单8-40
    }
    _status = (_direction == _AnimationDirection.forward) ?
           AnimationStatus.completed : AnimationStatus.dismissed;
    _checkStatusChanged(); // 更新动画状态,见代码清单8-40
    return TickerFuture.complete();
  } // if
  return _startSimulation(_InterpolationSimulation( // 第3步,开始动画
          _value, target, simulationDuration, curve, scale));
}

以上逻辑主要分为3步。第1步是simulationDuration的计算,将根据剩余值与总值的比例进行计算,stop的逻辑后面将会介绍。第2步主要是处理simulationDuration0的情况,即动画已经结束,此时进行字段的赋值和状态的通知。第3步,真正开始动画,注意此处Simulation的具体实现类是_InterpolationSimulation,它是一个线性插值模拟器,这部分内容后面将具体分析。首先分析_startSimulation的逻辑,如代码清单8-33所示。

// 代码清单8-33 flutter/packages/flutter/lib/src/animation/animation_controller.dart

double get value => _value;
TickerFuture _startSimulation(Simulation simulation) { // AnimationController
  _simulation = simulation;
  _lastElapsedDuration = Duration.zero; // 截止到上一帧动画,已消耗的时间
  _value = simulation.x(0.0).clamp(lowerBound, upperBound); 
// 起始值,x方法见代码清单8-39
  final TickerFuture result = _ticker!.start(); // 开始请求"心跳",驱动动画
  _status = (_direction == _AnimationDirection.forward) ?
                AnimationStatus.forward : AnimationStatus.reverse;
  _checkStatusChanged();
  return result;
}

以上逻辑通过_ticker!.start方法启动动画,同时更新_status的状态,start方法的逻辑如代码清单8-34所示。

// 代码清单8-34 flutter/packages/flutter/lib/src/scheduler/ticker.dart
TickerFuture start() {
  _future = TickerFuture._(); // 表示一个待完成的动画
  if (shouldScheduleTick) { scheduleTick(); } // 请求"心跳"
  if (SchedulerBinding.instance!.schedulerPhase.index
          > SchedulerPhase.idle.index
      && SchedulerBinding.instance!.schedulerPhase.index
          < SchedulerPhase.postFrameCallbacks.index) // 在这个阶段内应修正动画开始时间
    _startTime = SchedulerBinding.instance!.currentFrameTimeStamp;
  return _future!;
}

以上逻辑主要是通过scheduleTick方法发起一次“心跳”,其中有一个细节需要注意:如果当前正在处理一帧,那么_startTime会从当前一帧的Vsync信号的到达时间开始算起,这是一个小小的校正,即如果开始动画时已经有一帧在渲染,那么下一帧的动画状态应该相当于经过两帧的时间后的动画状态。scheduleTick方法的逻辑如代码清单8-35所示。

// 代码清单8-35 flutter/packages/flutter/lib/src/scheduler/ticker.dart
bool get muted => _muted; // 是否为静默状态
bool _muted = false;
bool get isActive => _future != null; // 存在动画

bool get scheduled => _animationId != null; // 已经在等待"心跳"
 // 调用逻辑见代码清单8-37,用于判断是否需要"心跳"
bool get shouldScheduleTick => !muted && isActive && !scheduled;

void scheduleTick({ bool rescheduling = false }) {
  _animationId = SchedulerBinding.instance!.scheduleFrameCallback(
                      _tick, rescheduling: rescheduling); // 见代码清单8-36
}

以上逻辑中,几个关键属性字段均已注明作用,scheduleFrameCallback方法会将_tick函数注册到下一个Vsync信号到达时的回调列表中,如代码清单8-36所示。

// 代码清单8-36 flutter/packages/flutter/lib/src/scheduler/ticker.dart
int scheduleFrameCallback(FrameCallback callback, { bool rescheduling = false }) {
  scheduleFrame(); // 见代码清单5-20
  _nextFrameCallbackId += 1;
  _transientCallbacks[_nextFrameCallbackId] // 处理逻辑见代码清单5-36
      = _FrameCallbackEntry(callback, rescheduling: rescheduling);
  return _nextFrameCallbackId;
}

_transientCallbacks将在Vsync信号到达后处理其回调callback,即_tick方法,如代码清单8-37所示。

// 代码清单8-37 flutter/packages/flutter/lib/src/scheduler/ticker.dart
void _tick(Duration timeStamp) { // Ticker
  _animationId = null;
  _startTime ??= timeStamp; // 首次"心跳"的时间戳,timeStamp是当次"心跳"的时间戳
  // 注意这里的 ??= 语法表明只会记录首次"心跳"发生时的时间戳
  _onTick(timeStamp - _startTime!); // 见代码清单8-38,参数为动画已经执行的时间
  if (shouldScheduleTick) scheduleTick(rescheduling: true); 
// 如有必要,等待下一次"心跳"
}

至此,可以肯定地说,所谓的“心跳”(tick)其实就是Vsync信号,_startTime只会赋值一次,表示动画开始的时间戳,接着以时间差为参数调用_onTick方法(即前面内容的参数——_tick函数),如果逻辑完成后shouldScheduleTicktrue,则会继续注册Vsync信号,用以驱动下一个“心跳”的产生。下面分析_tick方法的逻辑,如代码清单8-38所示。

// 代码清单8-38 flutter/packages/flutter/lib/src/animation/animation_controller.dart
// 该方法的注册逻辑位于代码清单8-29的createTicker方法中
void _tick(Duration elapsed) { // AnimationController
  _lastElapsedDuration = elapsed; // 动画已经执行的时间
  final double elapsedInSeconds = // 毫秒转秒
    elapsed.inMicroseconds.toDouble() / Duration.microsecondsPerSecond;
  assert(elapsedInSeconds >= 0.0);
  _value = _simulation!.x(elapsedInSeconds).clamp(lowerBound, upperBound); // 插值
  if (_simulation!.isDone(elapsedInSeconds)) { // 判断是否完成,见代码清单8-39
    _status = (_direction == _AnimationDirection.forward) ? // 完成则更新状态
                AnimationStatus.completed :AnimationStatus.dismissed;
    stop(canceled: false); // 停止动画,见代码清单8-41
  } // 否则,代码清单8-37中的shouldScheduleTick为true,将继续等待"心跳"(请求Vsync)
  notifyListeners(); // 见代码清单8-40
  _checkStatusChanged();
}

以上逻辑首先以秒为单位计算动画已经执行的时长,其次调用_simulationx方法计算当前的值,最后判断动画是否完成,同时广播自身的_value字段的变更。

首先分析x方法,Tween动画默认为_InterpolationSimulation,如代码清单8-39所示。

// 代码清单8-39 flutter/packages/flutter/lib/src/animation/animation_controller.dart
 // _InterpolationSimulation
double x(double timeInSeconds) {
  final double t = (timeInSeconds / _durationInSeconds).clamp(0.0, 1.0);
  if (t == 0.0) return _begin;
  else if (t == 1.0) return _end;
  else return _begin + (_end - _begin) * _curve.transform(t);
}
 // 是否完成完全取决于动画执行的时间,注意与代码清单8-44中介绍的物理动画进行区分
bool isDone(double timeInSeconds) => timeInSeconds > _durationInSeconds;

以上逻辑中,_curve默认为Curves.linear,即返回t本身,所以返回值即t的线性函数,系数为(_end - _begin)。

其次分析_value计算完成后的通知逻辑,如代码清单8-40所示。

// 代码清单8-40 flutter/packages/flutter/lib/src/animation/animation_controller.dart
void notifyListeners() {
  final List<VoidCallback> localListeners = List<VoidCallback>.from(_listeners);
  for (final VoidCallback listener in localListeners) {
    InformationCollector? collector;
    try {
      if (_listeners.contains(listener)) listener(); // 通知监听器
    } catch (exception, stack) { ...... }
  }
}
void _checkStatusChanged() {
  final AnimationStatus newStatus = status;
  if (_lastReportedStatus != newStatus) { // 状态发生改变才需要通知
    _lastReportedStatus = newStatus;
    notifyStatusListeners(newStatus);
  }
}

以上逻辑中,notifyListeners方法负责通知_value的变化,基本每帧都会调用;notifyStatusListeners负责通知状态的变化,只有动画状态改变时才会触发。

最后分析isDone的逻辑。对_InterpolationSimulation来说,其逻辑如代码清单8-39所示,即执行时间超过目标时间后停止。这也是补间动画的特点,即时间是动画“心跳”的决定因素,使用者只能定制具体的插值规则。

下面继续分析stop方法是如何停止动画的,如代码清单8-41所示。

// 代码清单8-41 flutter/packages/flutter/lib/src/animation/animation_controller.dart
void stop({ bool canceled = false }) {
  if (!isActive) return;
  final TickerFuture localFuture = _future!; 
  _future = null;
  _startTime = null;
  unscheduleTick(); // 停止等待"心跳"
  if (canceled) {
    localFuture._cancel(this);
  } else {
    localFuture._complete();
  }
}

以上逻辑主要是重置动画相关的字段,在此不再赘述。

总的来说,开发者也可以在build方法中通过Future+setState的方法驱动下一帧的执行,进而达到动画的效果,但是Animation提供了一套灵活、可拓展、资源易管理的框架,为开发者节省了大量精力。

总结:

补间动画本质还是向底层注册Vsync信号的回调:

  • ticker.start方法启动动画后,调用scheduleTick --> 调用SchedulerBinding.instance!.scheduleFrameCallback–>调用scheduleFrame() 走绘制流程,

  • 而在FrameCallback中会执行_tick方法,其中onTick中会调用notifyListeners()通知动画监听回调当前动画的value变化,基本每帧都会调用;notifyStatusListeners负责通知状态的变化,只有动画状态改变时才会触发。

补充:scheduleFrame方法的逻辑如代码清单5-20所示,它将通过window.scheduleFrame接口向Engine发起请求,要求在下一个Vsync信号到达的时候进行渲染。

// 代码清单5-20 flutter/packages/flutter/lib/src/scheduler/binding.dart
void scheduleFrame() {
  if (_hasScheduledFrame || !framesEnabled) return;
  ensureFrameCallbacksRegistered();  
  window.scheduleFrame(); 
  _hasScheduledFrame = true;
}

物理动画

补间动画虽然灵活,但其时间往往是固定的,对某些场景并不适用。以图8-6为例,当用户拖曳方块离开中心点时,如果希望方块以一个弹簧拖曳的动画效果回到原位置,那么补间动画很难实现,因为动画的完成时间取决于用户滑动的速度以及弹簧的各种属性。此时需要用到物理(Physics)动画。

Flutter 笔记 | Flutter 动画_第9张图片

代码清单8-42是对图8-6动画效果的关键实现代码。

// 代码清单8-42 物理动画示例
void _runAnimation(Offset pixelsPerSecond,  Size size) {
  // pixelsPerSecond表示拖曳手势结束、动画开始时
  // 方块由于拖曳手势而在X、Y方向上因物理惯性而产生的移动速度
  _animation = _controller.drive( // 触发AlignmentTween的animate方法
    AlignmentTween(begin: _dragAlignment, end: Alignment.center,));
  // 由6.2节可知,由于Alignment的坐标系是[0,1]形式的,因此需要除以屏幕大小,转为比例
  final unitsPerSecondX = pixelsPerSecond.dx / size.width;
  final unitsPerSecondY = pixelsPerSecond.dy / size.height;
  final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
  final unitVelocity = unitsPerSecond.distance;
  const spring = SpringDescription(mass: 30, stiffness: 1, damping: 1,); 
	// 弹簧的各种属性
  final simulation = SpringSimulation(spring, 0, 1, -unitVelocity); 
	// 构造一个弹簧物理模型
  _controller.animateWith(simulation);
}

void initState() {
  super.initState();
  _controller = AnimationController(vsync: this);
  _controller.addListener(() {
    setState(() { _dragAlignment = _animation.value;});
  });
}

以上逻辑中,pixelsPerSecond是一个拖曳手势的回调所携带的参数,表示当前用户滑动的速度,即该方块被拉出去的速度。_dragAlignment表示方块的实时位置,因此,当_runAnimation方法开始执行时,其值正好是动画的真实位置。至此,我们知道了方块的起点和终点,以及拖曳手势结束时的速度。接下来是滑动过程的物理模型的构造,主要是计算SpringSimulation的参数,unitVelocity即弹簧在直线方向上的滑动速度,与弹簧的方向相反。

下面直接分析animateWith方法,如代码清单8-43所示。

// 代码清单8-43 flutter/packages/flutter/lib/src/animation/animation_controller.dart
TickerFuture animateWith(Simulation simulation) {
  stop(); // 见代码清单8-41
  _direction = _AnimationDirection.forward;
  return _startSimulation(simulation); // 见代码清单8-33
}

由于_startSimulation的逻辑前面内容已经分析过,因此可以直接分析该SimulationxisDone方法,如代码清单8-44所示。

// 代码清单8-44 flutter/packages/flutter/lib/src/physics/spring_simulation.dart
class SpringSimulation extends Simulation {
  final _SpringSolution _solution; // 弹簧物理模型的抽象表示
  
  double x(double time) => _endPosition + _solution.x(time);
  
  double dx(double time) => _solution.dx(time);
  
  bool isDone(double time) {
    return nearZero(_solution.x(time), tolerance.distance) &&
           nearZero(_solution.dx(time), tolerance.velocity);
  }
}

由以上逻辑可知,SpringSimulation的主要逻辑交给了_solution字段,其由代码清单8-42中的SpringDescription创建,在此不对弹簧的物理模型做具体分析。SpringSimulationisDone方法也比较容易理解,即判断当前位置是否到达目标位置。这也是补间动画和物理动画的一个关键区别——isDone方法的实现:根据物理位置是否满足条件而非时间因素来判断是否结束。


参考:

  • 《Flutter实战·第二版》
  • 《Flutter内核源码剖析》

你可能感兴趣的:(Flutter,flutter,Flutter动画)