进阶 flutter.dev 2026-10-10 05:26:19 · 0 阅读

第12章 Flutter 从零到一(下):利用 Tween 为复合图形对象添加动画

第12章 Flutter 从零到一(下)

探讨如何在跨平台移动应用的场景下为复合图形对象添加动画

Mikkel Ravn

2017年3月31日 · 阅读约 18 分钟

探讨如何在跨平台移动应用的场景下为复合图形对象添加动画。跟随一位热衷于概念挖掘的作者,学习如何将 tween 概念应用到结构化数值的动画中,以柱状图为例。附完整代码示例,开箱即用。

编者注:本文已于2018年8月8日针对 Dart 2 更新,并于2018年10月17日添加了 GitHub 仓库和 diff 链接。

如何进入一个新的编程领域?动手实验显然是关键,研读和模仿更有经验的同行写的代码也很重要。我个人还喜欢辅以概念挖掘的方法:从第一性原理出发,识别概念、探索其威力、有意识地让概念指导实践。这是一种理性主义的方法,无法独立成立,但它能激发思考,让你更快获得更深的洞见。

这是 Flutter 及其 widget 和 tween 概念入门的第二部分,也是最后一部分。在第一部分的结尾,我们构建了一个 widget 树,其中除各种布局和状态管理 widget 外,还包括:

- 一个用自定义、支持动画的绘制代码来绘制单个柱形的 widget - 一个 floating action button widget,用于触发柱形高度的动画变化

柱形高度动画。

动画是用 BarTween 实现的,我当时声称 tween 概念可以扩展到更复杂的场景。本篇中我将兑现这个承诺:先把这个设计推广到具有更多属性的柱形,再推广到包含多个柱形、多种布局的柱状图。

先给单个柱形加上颜色。我们在 Bar 类的 height 字段旁加一个 color 字段,并更新 Bar.lerp 使其对两者都做插值。这是很典型的模式:

复合值之间的插值,就是对各个组件分别做插值。

回顾第一部分,"lerp" 是 "linearly interpolate"(线性插值)的缩写。

```dart import 'dart:ui' show lerpDouble;

import 'package:flutter/animation.dart'; import 'package:flutter/material.dart';

class Bar { Bar(this.height, this.color);

final double height; final Color color;

static Bar lerp(Bar begin, Bar end, double t) { return Bar( lerpDouble(begin.height, end.height, t), Color.lerp(begin.color, end.color, t), ); } }

class BarTween extends Tween { BarTween(Bar begin, Bar end) : super(begin: begin, end: end);

@override Bar lerp(double t) => Bar.lerp(begin, end, t); } ```

注意静态 lerp 方法的惯用法在这里的用处。如果没有 Bar.lerp、lerpDouble(相当于 double.lerp)和 Color.lerp,我们就得为 height 创建一个 Tween、为 color 创建一个 Tween 来实现 BarTween。这些 tween 会成为 BarTween 的实例字段,在构造函数中初始化,并在 lerp 方法中使用。这样一来,关于 Bar 属性的知识就会在 Bar 类之外被重复多次,代码维护者大概会觉得这并不理想。

柱形高度和颜色的动画。

为了在应用中使用带颜色的柱形,我们需要更新 BarChartPainter,让它从 Bar 中获取颜色。在 main.dart 中,我们需要能创建空的 Bar 和随机的 Bar:前者使用完全透明的颜色,后者使用随机颜色。颜色从一个简单的 ColorPalette 中取,我们单独建一个文件来快速实现它。我们把 Bar.empty 和 Bar.random 都做成 Bar 的工厂构造函数(代码清单、diff)。

柱状图涉及多种配置下的多个柱形。为了循序渐进地引入复杂性,我们的第一个实现适用于展示固定类别数值的柱状图,例如每个工作日的访客数或每季度的销售额。对于这类图表,把数据集换成另一周或另一年时,类别不变,只是每个类别对应的柱形发生变化。

这次我们先更新 main.dart,把 Bar 换成 BarChart,把 BarTween 换成 BarChartTween(代码清单、diff)。

为了让 Dart 分析器满意,我们在 bar.dart 中创建 BarChart 类,用一个固定长度的 Bar 列表来实现它。我们用五个柱形,每个工作日一个。然后需要把创建空实例和随机实例的职责从 Bar 移到 BarChart。类别固定的情况下,空柱状图可以合理地定义为空柱形的集合。但随机柱状图如果也是随机柱形的集合,图表会显得五彩斑斓。所以我们会为图表选一个随机颜色,让每个柱形(高度仍随机)继承它。

```dart import 'dart:math'; import 'dart:ui' show lerpDouble;

import 'package:flutter/animation.dart'; import 'package:flutter/material.dart';

import 'color_palette.dart';

class BarChart { static const int barCount = 5;

BarChart(this.bars) { assert(bars.length == barCount); }

factory BarChart.empty() { return BarChart(List.filled( barCount, Bar(0.0, Colors.transparent), )); }

factory BarChart.random(Random random) { final Color color = ColorPalette.primary.random(random); return BarChart(List.generate( barCount, (i) => Bar(random.nextDouble() * 100.0, color), )); }

final List bars;

static BarChart lerp(BarChart begin, BarChart end, double t) { return BarChart(List.generate( barCount, (i) => Bar.lerp(begin.bars[i], end.bars[i], t), )); } }

class BarChartTween extends Tween { BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

@override BarChart lerp(double t) => BarChart.lerp(begin, end, t); }

class Bar { Bar(this.height, this.color);

final double height; final Color color;

static Bar lerp(Bar begin, Bar end, double t) { return Bar( lerpDouble(begin.height, end.height, t), Color.lerp(begin.color, end.color, t), ); } }

class BarTween extends Tween { BarTween(Bar begin, Bar end) : super(begin: begin, end: end);

@override Bar lerp(double t) => Bar.lerp(begin, end, t); }

class BarChartPainter extends CustomPainter { static const barWidthFraction = 0.75;

BarChartPainter(Animation animation) : animation = animation, super(repaint: animation);

final Animation animation;

@override void paint(Canvas canvas, Size size) { void drawBar(Bar bar, double x, double width, Paint paint) { paint.color = bar.color; canvas.drawRect( Rect.fromLTWH(x, size.height - bar.height, width, bar.height), paint, ); }

final paint = Paint()..style = PaintingStyle.fill; final chart = animation.value; final barDistance = size.width / (1 + chart.bars.length); final barWidth = barDistance * barWidthFraction; var x = barDistance - barWidth / 2; for (final bar in chart.bars) { drawBar(bar, x, barWidth, paint); x += barDistance; } }

@override bool shouldRepaint(BarChartPainter old) => false; } ```

BarChartPainter 把可用宽度平均分配给各个柱形,并让每个柱形占其可用宽度的 75%。

固定类别的柱状图。

注意 BarChart.lerp 是基于 Bar.lerp 实现的,动态重新生成列表结构。固定类别的柱状图就是复合值,逐组件插值正是合理的做法,和具有多个属性的单个柱形如出一辙(diff)。

这里有一个可循的规律:当 Dart 类的构造函数接受多个参数时,通常可以分别对每个参数做插值,组合起来的效果也不会差。而且这个规律可以任意嵌套:仪表盘的插值,可以归结为其组成柱状图的插值;柱状图的插值归结为其柱形的插值;柱形的插值归结为其高度和颜色的插值;颜色则归结为 RGB 和 alpha 分量的插值。递归的尽头,是对数字做插值。

数学爱好者可能会这样表述:插值与结构可交换,即对复合值 *C*(*x*, *y*) 有:

*lerp*(*C*(*x*1, *y*1), *C*(*x*2, *y*2), *t*) == *C*(*lerp*(*x*1, *x*2, *t*), *lerp*(*y*1, *y*2, *t*))

如前所述,这个规律可以很好地从两个分量(柱形的高度和颜色)推广到任意多个分量(固定类别柱状图的 n 个柱形)。

但有些情况下这个美好图景会失效:我们可能想在两个结构不同的值之间做动画。举个简单的例子,从包含五个工作日数据的柱状图动画过渡到还包含周末的柱状图。

你可能很快能想到几种不同的临时方案,然后让 UX 设计师从中挑选。这是合理的做法,不过我认为讨论时值得记住这些方案的共同基础结构:tween。回顾第一部分:

通过在所有 T 构成的空间中画出一条路径来实现 T 的动画,动画值从零到一。用 Tween 来建模这条路径。

需要和 UX 设计师回答的核心问题是:五根柱子的图表和七根柱子的图表,中间值是什么?显然可以选六根柱子,但要平滑动画,需要更多的中间值。我们必须改变柱形的绘制方式,走出等宽、均匀分布、适配 200 像素柱形的空间。换句话说,T 值的空间需要泛化。

对结构不同的值做插值时,先把它们嵌入到一个更一般的空间中,该空间同时涵盖动画的两个端点和所有需要的中间值。

分两步实现。第一步,泛化 Bar,把它的 x 坐标和宽度也纳入属性:

```dart class Bar { Bar(this.x, this.width, this.height, this.color);

final double x; final double width; final double height; final Color color;

static Bar lerp(Bar begin, Bar end, double t) { return Bar( lerpDouble(begin.x, end.x, t), lerpDouble(begin.width, end.width, t), lerpDouble(begin.height, end.height, t), Color.lerp(begin.color, end.color, t), ); } } ```

第二步,让 BarChart 支持不同柱形数量的图表。新图表适用于柱形 i 表示某个序列中第 i 个值的数据集,比如产品发布后第 i 天的销量。按程序员的计数方式,任何这类图表都为每个整数 0..n 包含一个柱形,只是 n 可能因图表而异。

考虑分别有五根和七根柱子的两个图表。它们共同类别 0..5 的柱形可以按前述方式组合地做动画。索引 5 和 6 的柱形在另一个动画端点中没有对应物,但由于我们现在可以自由设定每个柱形的位置和宽度,可以引入两根不可见的柱形来扮演那个角色。视觉效果就是第 5、6 根柱子在动画进行中逐渐长成最终形态。反向动画时,第 5、6 根柱子则会缩小或淡出至不可见。

复合值之间插值就是对应组件之间插值。当某个组件在一端缺失时,用一个不可见的组件代替。

不可见组件通常有多种选择。假设我们的 UX 设计师朋友决定用零宽零高的柱形,其 x 坐标和颜色继承自可见的对应柱形。我们给 Bar 加一个方法,用于创建给定实例的这种坍缩版本。

```dart class BarChart { BarChart(this.bars);

final List bars;

static BarChart lerp(BarChart begin, BarChart end, double t) { final barCount = max(begin.bars.length, end.bars.length); final bars = List.generate( barCount, (i) => Bar.lerp( begin._barOrNull(i) ?? end.bars[i].collapsed, end._barOrNull(i) ?? begin.bars[i].collapsed, t, ), ); return BarChart(bars); }

Bar _barOrNull(int index) => (index < bars.length ? bars[index] : null); }

class BarChartTween extends Tween { BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

@override BarChart lerp(double t) => BarChart.lerp(begin, end, t); }

class Bar { Bar(this.x, this.width, this.height, this.color);

final double x; final double width; final double height; final Color color;

Bar get collapsed => Bar(x, 0.0, 0.0, color);

static Bar lerp(Bar begin, Bar end, double t) { return Bar( lerpDouble(begin.x, end.x, t), lerpDouble(begin.width, end.width, t), lerpDouble(begin.height, end.height, t), Color.lerp(begin.color, end.color, t), ); } } ```

把上述代码整合进应用时,需要在这个新设定下重新定义 BarChart.empty 和 BarChart.random。空柱状图现在可以合理地定义为零根柱形;随机柱状图可以包含随机数量的柱形,颜色相同且随机,高度各自随机。但由于位置和宽度现在都是 Bar 定义的一部分,BarChart.random 也需要指定这些属性。合理的做法是给 BarChart.random 传入图表的 Size 参数,然后把 BarChartPainter.paint 中的大部分计算转移出去(代码清单、diff)。

与不可见柱形之间的插值。

细心的读者可能已经注意到上面 BarChart.lerp 定义中潜在的效率问题:我们创建 collapsed 的 Bar 实例只是作为 Bar.lerp 的参数,而且对动画参数 t 的每个取值都会重复这一操作。在每秒 60 帧下,即使动画很短,也可能产生大量 Bar 实例交给垃圾回收器。有以下几种替代方案:

- collapsed 的 Bar 实例可以在 Bar 类中只创建一次并复用,而不是每次调用 collapsed 都新建。这个方案在这里可行,但并不普遍适用。 - 复用逻辑可以交给 BarChartTween 处理:让它的构造函数创建一个 BarTween 实例列表 _tweens,在生成插值柱状图时使用:(i) => _tweens[i].lerp(t)。这打破了全程使用静态 lerp 方法的惯例。静态的 BarChart.lerp 中没有对象可以在动画期间保存 tween 列表,而 BarChartTween 对象恰恰适合承担这个角色。 - 可以用 null 柱形表示坍缩柱形,前提是在 Bar.lerp 中加入适当的条件逻辑。这个方案简洁高效,但需要小心避免解引用 null 或误解其含义。Flutter SDK 中普遍使用这种做法:静态 lerp 方法通常接受 null 作为动画端点,一般将其解释为某种不可见元素,比如完全透明的颜色或零尺寸的图形元素。最基本的例子是 lerpDouble,除非两个端点都是 null,否则它把 null 当作零处理。

下面的代码片段展示了按 null 方案会怎么写:

```dart class BarChart { BarChart(this.bars);

final List bars;

static BarChart lerp(BarChart begin, BarChart end, double t) { final barCount = max(begin.bars.length, end.bars.length); final bars = List.generate( barCount, (i) => Bar.lerp(begin._barOrNull(i), end._barOrNull(i), t), ); return BarChart(bars); }

Bar _barOrNull(int index) => (index < bars.length ? bars[index] : null); }

class BarChartTween extends Tween { BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

@override BarChart lerp(double t) => BarChart.lerp(begin, end, t); }

class Bar { Bar(this.x, this.width, this.height, this.color);

final double x; final double width; final double height; final Color color;

static Bar lerp(Bar begin, Bar end, double t) { if (begin == null && end == null) return null; return Bar( lerpDouble((begin ?? end).x, (end ?? begin).x, t), lerpDouble(begin?.width, end?.width, t), lerpDouble(begin?.height, end?.height, t), Color.lerp((begin ?? end).color, (end ?? begin).color, t), ); } } ```

可以说 Dart 的 ? 语法很适合这个任务。但注意,用坍缩柱形(而非比如透明柱形)作为不可见元素的决定,现在被埋进了 Bar.lerp 的条件逻辑里。这是我之前选择表面上看效率较低的方案的主要原因。性能与可维护性之间的取舍,一如既往,应当基于实测来做决定。

在完全通用地处理柱状图动画之前,还差最后一步。设想一个应用用柱状图展示某一年的按产品类别的销售额。用户可以选择另一年份,应用应动画过渡到那一年的柱状图。如果两年的产品类别相同,或者恰好除了某一年在右侧多出几个类别之外都相同,我们可以直接用上面的代码。但如果公司在 2016 年有产品类别 A、B、C、X,而 2017 年停掉了 B、新增了 D 呢?用现有代码,动画会是这样:

``` 2016 2017 A -> A B -> C C -> D X -> X ```

动画可能华丽丝滑,但用户仍会感到困惑。为什么?因为它没有保持语义。它把代表产品类别 B 的图形元素变成了代表类别 C 的元素,而原本的 C 却跑到了别处。2016 年的 B 恰好画在 2017 年 C 之后出现的位置上,并不意味着前者应变形为后者。正确的做法是:2016 年的 B 消失,2016 年的 C 左移并变形为 2017 年的 C,2017 年的 D 在其右侧出现。我们可以用一个古老而经典的算法来实现这种交织:合并有序列表。

复合值之间插值就是语义上对应组件之间插值。当组件构成有序列表时,可用合并算法使这类组件一一对应,单侧缺失时用不可见组件补位。

所需的只是让 Bar 实例可以在一个线性序上互相比较。然后就能如下合并:

```dart static BarChart lerp(BarChart begin, BarChart end, double t) { final bars = []; final bMax = begin.bars.length; final eMax = end.bars.length; var b = 0; var e = 0; while (b + e < bMax + eMax) { if (b < bMax && (e == eMax || begin.bars[b] < end.bars[e])) { bars.add(Bar.lerp(begin.bars[b], begin.bars[b].collapsed, t)); b++; } else if (e < eMax && (b == bMax || end.bars[e] < begin.bars[b])) { bars.add(Bar.lerp(end.bars[e].collapsed, end.bars[e], t)); e++; } else { bars.add(Bar.lerp(begin.bars[b], end.bars[e], t)); b++; e++; } } return BarChart(bars); } ```

具体做法是给每个柱形一个整数 rank 属性作为排序键。rank 还可以方便地用来从调色板中为柱形分配颜色,便于在动画演示中追踪单个柱形的移动。

随机柱状图现在基于随机选中的一组 rank 来构建(代码清单、diff)。

任意类别,基于合并的插值。

这样很好用,但可能不是最高效的方案:我们在 BarChart.lerp 中反复执行合并算法,t 的每个取值执行一次。为了解决这个问题,我们实现前面提到的想法:把可复用的信息存储在 BarChartTween 中。

```dart class BarChartTween extends Tween { BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end) { final bMax = begin.bars.length; final eMax = end.bars.length; var b = 0; var e = 0; while (b + e < bMax + eMax) { if (b < bMax && (e == eMax || begin.bars[b] < end.bars[e])) { _tweens.add(BarTween(begin.bars[b], begin.bars[b].collapsed)); b++; } else if (e < eMax && (b == bMax || end.bars[e] < begin.bars[b])) { _tweens.add(BarTween(end.bars[e].collapsed, end.bars[e])); e++; } else { _tweens.add(BarTween(begin.bars[b], end.bars[e])); b++; e++; } } }

final _tweens = [];

@override BarChart lerp(double t) => BarChart( List.generate( _tweens.length, (i) => _tweens[i].lerp(t), ), ); } ```

现在可以删除静态的 BarChart.lerp 方法(diff)。

总结一下到目前为止关于 tween 概念的要点:

通过在所有 T 构成的空间中画出一条路径来实现 T 的动画,动画值从零到一。用 Tween 来建模这条路径。

按需泛化 T 概念,直到它涵盖所有动画端点和中间值。

复合值之间插值就是对应组件之间插值。

- 对应关系应基于语义,而非图形上的偶然共位。 - 当某个组件在一端缺失时,用一个不可见的组件代替,可以由另一端派生。 - 当组件构成有序列表时,用合并算法使语义对应的组件配对,单侧缺失时引入不可见组件补位。

考虑用静态 Xxx.lerp 方法实现 tween,以便在复合 tween 的实现中复用。当一次动画路径中反复调用 Xxx.lerp 导致大量重复计算时,考虑把计算移到 XxxTween 类的构造函数中,让实例持有计算结果。

有了这些洞见,我们终于可以为更复杂的图表做动画了。接下来依次实现堆叠柱状图、分组柱状图和堆叠+分组柱状图:

堆叠柱状图适用于类别是二维、且柱形高度所代表的数值可以相加的数据集。例如每个产品在每个地理区域的收入。按产品堆叠便于比较产品在全球市场的表现,按区域堆叠则显示哪些区域更重要。

堆叠柱状图。

分组柱状图同样适用于二维类别的数据集,但堆叠无意义或不理想。例如数值是按产品和区域划分的市场份额百分比,按产品堆叠就没有意义。即使堆叠有意义,分组可能更好,因为它便于同时跨两个类别维度做定量比较。

分组柱状图。

堆叠+分组柱状图支持三维类别,例如按产品、地理区域和销售渠道划分的收入。

堆叠+分组柱状图。

在所有这三种变体中,动画都可以用来可视化数据集的变化,从而在不弄乱图表的前提下引入额外维度(通常是时间)。

为了让动画有用而不只是好看,我们必须确保只在语义对应的组件之间做插值。也就是说,2016 年表示某个产品/区域/渠道收入的柱形段,应变形为 2017 年表示同一产品/区域/渠道收入的柱形段(如果存在)。

合并算法可以保证这一点。你可能已经从前面的讨论猜到,合并会在多个层级上发挥作用,与类别的维度相对应:堆叠图中合并堆和柱形,分组图中合并组和柱形,堆叠+分组图中三者都合并。

为了避免大量代码重复,我们把合并算法抽象成一个通用工具,单独放在 tween.dart 文件中:

```dart import 'package:flutter/animation.dart'; import 'package:flutter/material.dart';

abstract class MergeTweenable { T get empty;

Tween tweenTo(T other);

bool operator <(T other); }

class MergeTween> extends Tween> { MergeTween(List begin, List end) : super(begin: begin, end: end) { final bMax = begin.length; final eMax = end.length; var b = 0; var e = 0; while (b + e < bMax + eMax) { if (b < bMax && (e == eMax || begin[b] < end[e])) { _tweens.add(begin[b].tweenTo(begin[b].empty)); b++; } else if (e < eMax && (b == bMax || end[e] < begin[b])) { _tweens.add(end[e].empty.tweenTo(end[e])); e++; } else { _tweens.add(begin[b].tweenTo(end[e])); b++; e++; } } }

final _tweens = >[];

@override List lerp(double t) => List.generate( _tweens.length, (i) => _tweens[i].lerp(t), ); } ```

MergeTweenable 接口精确地捕捉了通过合并两个有序 T 列表来创建 tween 所需的能力。我们会分别用 Bar、BarStack 和 BarGroup 实例化类型参数 T,并让这些类型都实现 MergeTweenable(diff)。

堆叠(diff)、分组(diff)和堆叠+分组(diff)的写法刻意保持可直接对比。我鼓励你把玩这些代码:

- 修改 BarChart.random 创建的组、堆、柱形数量。 - 修改调色板。堆叠+分组柱状图我用了单色调色板,因为我觉得更好看。你和你设计师可能有不同看法。 - 用年份选择器替换 BarChart.random 和 floating action button,并用真实数据集创建 BarChart 实例。 - 实现水平柱状图。 - 实现其他图表类型(饼图、折线图、堆叠面积图),用 MergeTweenable 或类似方式做动画。 - 添加图例和/或标签、坐标轴,然后也为它们做动画。

最后两项任务相当有挑战性。祝玩得开心。

来自 Flutter 的更多内容

如何把 GenLatte 迁移到全栈 Dart——还降低了应用的服务器开销

Craig Labenz

2026年9月22日 · 阅读约 7 分钟

用 A2UI 的客户端函数实现快速可靠的计算

了解客户端函数如何让 agent 把本地操作直接委托给用户设备上运行的 Dart 代码。

Andrew Brogdon

2026年8月28日 · 阅读约 5 分钟

评论 (0)