第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
@override Bar lerp(double t) => Bar.lerp(begin, end, t); } ```
注意静态 lerp 方法的惯用法在这里的用处。如果没有 Bar.lerp、lerpDouble(相当于 double.lerp)和 Color.lerp,我们就得为 height 创建一个 Tween
柱形高度和颜色的动画。
为了在应用中使用带颜色的柱形,我们需要更新 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
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
@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
@override Bar lerp(double t) => Bar.lerp(begin, end, t); }
class BarChartPainter extends CustomPainter { static const barWidthFraction = 0.75;
BarChartPainter(Animation
final 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
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
@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
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
@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 =
具体做法是给每个柱形一个整数 rank 属性作为排序键。rank 还可以方便地用来从调色板中为柱形分配颜色,便于在动画演示中追踪单个柱形的移动。
随机柱状图现在基于随机选中的一组 rank 来构建(代码清单、diff)。
任意类别,基于合并的插值。
这样很好用,但可能不是最高效的方案:我们在 BarChart.lerp 中反复执行合并算法,t 的每个取值执行一次。为了解决这个问题,我们实现前面提到的想法:把可复用的信息存储在 BarChartTween 中。
```dart
class BarChartTween extends Tween
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
Tween
bool operator <(T other); }
class MergeTween> {
MergeTween(List
final _tweens =
@override
List
MergeTweenable
堆叠(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 分钟