原文地址:Zero to One with Flutter, Part Two
原文作者:Mikkel Ravn
译注:前文已经说到如何做柱状图的高度,颜色以及不同数量bar的动画。本文继续讲解如何用一个古老的算法来实现堆叠柱,分组柱,堆叠的分组柱等更复杂的柱状图
第一部分:[译]Flutter动画图表浅入深出(一):Flutter、补间和动画基础
第二部分:[译]Flutter动画图表浅入深出(二):图表的高度,颜色和数量动画
我们离完成一个最通用的柱状图动画还差最后一步。考虑一下这么一个app,它需要用柱状图来表示一年里某个产品的销量。用户可以选择年份,而app里的柱状图要从当前的年份以动画形式切换到选中的年份。如果两年的产品类别都是一样的或者多出来的产品类别碰巧都在图表的的最右边,那我们可以用我们上文得到的方案。但是如果我们的产品在2016年有A,B,C和X四种而在2017年里B没有了,却多出来了D产品怎么办呢?我们上文中代码的动画效果是这样的:
2016 2017
A -> A
B -> C
C -> D
X -> X
这个动画效果很漂亮,如丝般顺畅。不过它却可能让用户很困惑。为什么呢?因为它没有保持柱状图里的语义:它把表示B产品类别的bar直接转换成了表示C产品的,而原先表示C产品的bar消失了。不能仅仅因为2016年的B产品出现的位置刚好和2017年的C产品一样,就可以把它直接转换成后者。相反,2016年的B产品应该消失。而2017年的C产品应该向左移动,变成2017年的C产品,2017年的D产品应该出现在它的右边。要实现这种交织动画,我们可以用教科书上最古老的算法之一:归并排序中的有序列表合并。
对复合值的线性插值就等于对其中对应的分量进行保持语义的线性插值。如果一个分量是有序列表,那么归并算法也可以用相同的办法来处理有序列表中的元素。必要时,使用隐形元素来处理单边合并问题。
我们要做的就是让Bar
类的实例可以按线性顺序相互比较。我们可以如下所示合并他们:
static BarChart lerp(BarChart begin, BarChart end, double t) {
final bars = <Bar>[];
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字段来作为每个bar的排序键值。rank值也可以用来很方便的为我们的bar从调色盘中获取对应的颜色,这样我们就可以追踪动画示例中每个bar的移动。
我们的随机柱状图现在可以根据随机的rank值来展示动画了。( 代码, 代码差异 )
效果看起来很不错。不过这还不是最高效的解决办法。在BarChart.lerp
中,对每一个不同的t
.值我们都执行了一次排序算法。要解决这个问题,我们可以用先前提到的办法,把可重用的信息先存储在BarChartTween
类里,避免重复计算:
class BarChartTween extends Tween<BarChart> {
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 = <BarTween>[];
@override
BarChart lerp(double t) => BarChart(
List.generate(
_tweens.length,
(i) => _tweens[i].lerp(t),
),
);
}
现在我们可以删除静态的BarChart.lerp
方法了。(代码差异)
让我们总结一下目前为止关于tween这个概念我们学到了什么:
当动画值从0变为1时( 译注:tween里的t值),Tween在类型T的取值空间里画出了一条路径。这个路径可以用Tween<T>来建模。
按需求扩展范型T,直到它包括了动画起始点和中间值所需的各种属性
对复合值插值就是对其各个组成分量插值:
- 插值动画应该是有语义的,而不是根据碰巧的位置重叠
- 如果一个分量在动画的起点或终点不存在,那么我们可以用一个隐形的分量来代替它,这个隐形分量可能是从动画另外一端的对应分量转换而来的
- 如果分量是一个有序列表,使用合并算法来保持图表变化时的语义。必要时,引入隐形列表元素来处理单边合并问题
使用静态Xxx.lerp
方法来实现复合值的线性插值,这样可以方便代码重用 。
如果一条动画路径在Xxx.lerp
的多次调用时存在大量的重复计算,可以考虑把重复的计算放到XxxTween
类的构造函数中,由该类的实例保存计算结果。
有了以上的洞见,我们终于可以实现更复杂的图表了。下面我们就快速地依次略览一下堆叠柱状图,分组柱状图和堆叠分组柱状图:
- 堆叠柱状图用来展示有两个类别维度数据集。把数据集其中一个维度的数据相加,并用bar的高度来表示是有一定意义的。比如同时按产品类别和按地区来划分的收入。按产品叠加可以很容易的比较各个产品在全球市场上的表现。按地区叠加可以看出各个地区的重要程度。
- 分组柱状图也可以用来展示二维度类别数据集,但是把一维中的数据叠加没有必要或没有意义。比如用百分比来表示的,按产品和地区来划分的市场份额,如果把产品的数值叠加起来就没有意义。即使叠加是有意义的,分组柱状图也可能更适合。因为它可以更容易的同时比较两种类别。
- 堆叠分组柱状图支持有三个维度的数据集。比如按产品,地区和销售渠道划分的收入。
在上面三种图表中,动画可以让数据的改变可视化,从而隐式地引入了一个附加的维度(一般来说是时间)而不会使图表显得混乱。
要让图表不只是看起来好看而且还有用,我们需要确保我们的线性插值只在有对应关系的分量中之间进行。所以用来表示2016年某个产品/地区/渠道之收入的bar,如果有的话,应该被转换为2017年的对应bar。
这里也可以使用有序列表的合并算法。有了之前讨论的基础,你应该已经想到了,合并会在多个层次进行,以使得每个维度都能在图表中反映出来。在堆叠柱状图中我们会合并堆叠和bar,在分组柱状图中我们会合并分组和bar,而在分组堆叠图中我们会合并堆叠,分组和bar。
为了避免过多的重复代码,我们把合并算法抽离出来,作为一个通用的工具类,放到一个单独的文件tween.dart
里:
import 'package:flutter/animation.dart';
import 'package:flutter/material.dart';
abstract class MergeTweenable<T> {
T get empty;
Tween<T> tweenTo(T other);
bool operator <(T other);
}
class MergeTween<T extends MergeTweenable<T>> extends Tween<List<T>> {
MergeTween(List<T> begin, List<T> 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 = <Tween<T>>[];
@override
List<T> lerp(double t) => List.generate(
_tweens.length,
(i) => _tweens[i].lerp(t),
);
}
MergeTweenable<T>
抽象了合并两个有序列表所需要的接口。 我们用Bar
, BarStack
和 BarGroup
实例化范型参数 T, 并分别实现MergeTweenable<T>
(代码差异).
堆叠柱状图 (代码差异), 分组柱状图 (代码差异)和 堆叠分组柱状图 (代码差异) 都可以进行直接比较。我推荐你试试以下几件事情:
- 在
BarChart.random
中改变生成的组数,堆叠数和bar数 - Ch调色板。对于堆叠分组柱状图,我建议使用单色调色板,我觉得这样好看一些. 你和你的UX设计师可能并不这么认为
- 把
BarChart.random
类和浮动按钮换掉。换成一个年份选择器并从真实的数据中生成BarChart
类的实例 - 实现水平柱状图
- 实现其他类型的图表,如饼图,折线图,堆叠区域图,用
MergeTweenable<T>
类或类似的办法来为他们添加动画效果 - 为图表添加图例,标签和轴线,也需要为他们添加动画效果
最后两项非常有挑战性,去挑战一下,享受编程的乐趣吧。