2、补间动画Tween:Tween用法、线性插值、自定义Tween、链式动画

好,咱们接着聊补间动画。说白了,Tween 就是 Flutter 动画里最基础的那个“数值搬运工”。你给它一个起始值、一个结束值,它就在中间帮你算出所有过渡帧。我刚开始学 Flutter 动画时,觉得这玩意儿太简单了,不就是个线性插值嘛。后来踩了几个坑才发现,Tween 的玩法比想象中深得多。

2.1 Tween 的基本用法

先看最基础的用法。你有一个 AnimationController,它输出 0.0 到 1.0 的数值。Tween 的作用,就是把 0.0~1.0 映射到你想要的区间。

final AnimationController controller = AnimationController(
  duration: Duration(seconds: 2),
  vsync: this,
);

final Animation<double> animation = Tween<double>(
  begin: 0.0,
  end: 200.0,
).animate(controller);

这段代码,就是把 0~1 映射成 0~200。controller 走到 0.5 时,animation.value 就是 100.0。嗯,这里要注意:Tween 本身不驱动动画,它只是做映射。驱动动画的还是 controller。

我的习惯:我一般把 Tween 和 controller 分开声明。这样方便复用 Tween,也方便调试。你想想看,如果哪天要改动画范围,改 Tween 就行,controller 不用动。

2.2 线性插值的本质

Tween 默认用的是线性插值。什么意思?就是匀速变化。从 begin 到 end,时间走多少,数值就变多少。

公式其实很简单:

value = begin + (end - begin) * t

其中 t 是 0.0~1.0 的进度。这个公式,就是 Tween 的 lerp 方法干的事。

我在项目中遇到过一个问题:用 Tween<double> 做透明度动画,从 0.0 到 1.0,看起来没问题。但换成 Tween<Color> 时,发现颜色过渡有点“愣”。为什么?因为 Color 的线性插值,是在 RGB 空间里直接算的。红绿蓝各自线性变化,结果就是颜色会经过一些“脏”的中间色。

避坑指南:我曾经在做一个渐变色动画时,用了默认的 ColorTween,结果过渡效果特别生硬。后来改用 HSV 空间的插值,效果就好多了。如果你对颜色过渡有要求,建议自己写个自定义 Tween。

2.3 自定义 Tween

Flutter 内置了 DoubleTween、ColorTween、SizeTween 等。但有时候你需要处理自定义类型,比如一个自定义的渐变对象,或者一个复杂的数学曲线。这时候就得自己写 Tween。

自定义 Tween 其实很简单,继承 Tween<T>,然后重写 lerp 方法就行。

class GradientTween extends Tween<Gradient> {
  GradientTween({Gradient? begin, Gradient? end})
      : super(begin: begin, end: end);

  @override
  Gradient lerp(double t) {
    // 这里实现你自己的插值逻辑
    return LinearGradient(
      colors: [
        Color.lerp(begin!.colors[0], end!.colors[0], t)!,
        Color.lerp(begin!.colors[1], end!.colors[1], t)!,
      ],
    );
  }
}

你看,核心就是 lerp 方法。它接收一个 t 值(0.0~1.0),返回你想要的中间状态。我建议你在写自定义 Tween 时,先想清楚你的数据在“中间状态”应该长什么样。比如一个圆角矩形的 borderRadius,中间状态就是四个角半径的线性插值。

注意:自定义 Tween 的 lerp 方法会被频繁调用(每帧一次)。如果你的插值逻辑里有耗时操作,比如图片解码、网络请求,那动画就会卡顿。我曾经犯过这个错,在 lerp 里做了字符串拼接和正则匹配,结果动画掉帧严重。后来改成预计算,问题就解决了。

2.4 链式动画

链式动画,说白了就是让动画一个接一个地跑。Flutter 里实现链式动画,最常用的方式是 TweenSequence

final Animation<double> animation = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween<double>(begin: 0.0, end: 100.0),
    weight: 1,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: 100.0, end: 200.0),
    weight: 2,
  ),
]).animate(controller);

这里 weight 控制每个阶段的时间占比。第一个阶段 weight 是 1,第二个是 2,所以第二个阶段占的时间是第一个的两倍。总时长还是由 controller 的 duration 决定。

我个人习惯用 TweenSequence 做“弹跳效果”。比如一个按钮先放大,再缩小回原样。用两个 Tween 串起来,比用 Curves 调半天要直观得多。

还有一种链式动画的方式,是用 addStatusListener 监听动画状态,在动画结束时启动下一个动画。这种方式更灵活,但代码会分散一些。

controller.addStatusListener((status) {
  if (status == AnimationStatus.completed) {
    // 启动下一个动画
    secondController.forward();
  }
});
我的建议:如果链式动画的每个阶段都是简单的数值变化,用 TweenSequence 就够了。如果每个阶段需要不同的曲线、不同的持续时间,或者需要条件判断,那就用 addStatusListener。我一般在做复杂的交互动画时,会混合使用这两种方式。

2.5 实战小技巧

最后分享几个我在项目中积累的小技巧:

  • 复用 Tween 对象:Tween 是不可变对象,可以安全复用。我一般把常用的 Tween 定义成静态常量,避免重复创建。
  • 调试时打印中间值:addListener 里打印 animation.value,能帮你快速定位插值问题。
  • 注意边界情况:t 值可能小于 0 或大于 1(比如用了弹性曲线)。你的自定义 Tween 要能处理这些边界值,否则会崩溃。
  • evaluate 方法:如果你只想算一次插值,不想创建 Animation,可以直接用 tween.evaluate(controller)。这个我经常在初始化时用。

嗯,Tween 这块内容就这些。说白了,它就是动画的“翻译官”,把 controller 的 0~1 翻译成你需要的任何数值。掌握了自定义 Tween,你就能做出各种花里胡哨的动画效果。下一章咱们聊聊曲线动画,看看怎么让动画“动得更有感觉”。