原文作者:Mikkel Ravn
第一部分:[译]Flutter动画图表浅入深出(一):Flutter、补间和动画基础
第三部分:[译]Flutter动画图表浅入深出(三):用古老算法解决复杂图表的终极问题
在跨平台环境下发现进行组合动画开发的技巧。
跟着一位酷爱挖掘概念的人学习如何以柱状图为示例,把补间概念应用到结构化数据中。
2018 年 8 月 8 日用 Dart 2更新。2018 年 10 月 17 日添加了GitHub 仓库和diff链接。
如何进入一个新的编程领域呢?很明显,关键就是不仅要动手实践而且还要多学习和效仿更有经验的同行写的程序。
除此之外,我个人还喜欢应用概念挖掘方法: 试着从基本的原理出发,发现各种概念,挖掘他们的能力,有意识的应用他们以引导我们达到目的。虽然这种方法不能脱离其他方法单独应用,但这是一种合理的智力刺激方法,可以带领你更快获得更深刻的洞见。
这是Flutter,Widgets和tween概念介绍文章的第二部分,也是最后一部分。在第一部分中,我们除了构建了一颗包括各种布局和状态处理的widget的树之外,还构建了:
- 一个绘制了单一bar的widget,其中使用了可感知动画的绘图代码
- 一个可以改变bar的高度并触发动画的浮动按钮
Bar高度动画
动画使用了BarTween
来实现,我先前说tween这个概念也可以扩展应用到更复杂的情况。在本文中,我将展示如何可以把这个概念也应用到有多种属性的bar并有不同配置的柱状图中。
我们首先给现有的单个bar添加颜色。在Bar
类中的height字段旁边添加一个color字段,然后在Bar.lerp
中同时更新着这两个字段。
这是个典型的模式:lerp 一个复合值即 lerp 这个值一个个独立的分量。
还记得我们在第一部分说过,lerp就是线性插值的缩写。
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<Bar> {
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
方法,为了实现BarTween
,我们就不得不在BarTween类中,为高度创建一个Tween <double>
实例,而为颜色创建一个Tween <color>
实例,然后在构造函数中初始化他们,并把他们用在它的lerp
方法中。这样我们就在Bar
类之外,多处访问它的内部属性。后继的维护者会发现,这样的(译注:缺乏封装的)代码不是那么好。
Bar的高度和颜色同时进行的动画
为了在app中使用彩色bar,我们将更新BarChartPainter
并从Bar
类中获取颜色。在main.dart
里,我们要可以创建一个空的Bar
和一个随机的Bar
。我们把前者的的颜色设置为全透明的,后者的颜色设为随机。我们从一个简单的调色盘类(ColorPalette
)中获取颜色,它的代码放在一个单独的文件中,稍后我们再导入这个文件。我们在Bar
类中创建Bar.empty
和Bar.random
两个工厂构造函数。( 代码, 代码差异 )
柱状图可以有不同配置和多个bar。为了不一下子就弄的那么复杂,我们先实现显示数量固定的类别对应的数值的柱状图,比如每个工作日的到访者和每季度的销售额。对于这类图表,切换到另一周或者另一年不会改变显示的类别的数量,而只会改变各个bar的高度。
我们这次先更新main.dart
,把Bar
替换为BarChart
,把BarTween
替换为用BarChartTween
。( 代码, 代码差异)
为了让Dart分析器开心(译注:为了通过编译),我们在bar.dart
中创建BarChart
类,用一个固定长度的bar列表来实现它。我们用五根bar,每根代表一周中的一个工作日。然后我们把创建空bar和随机bar的代码从Bar
类移到BarChart
类。在类别数量固定的情况下,空的柱状图,理所当然,就由一个空柱子的集合构成。而随机柱状图,如果也是由随机bar的集合构成的话,会让我们的图表看起来有点眼花缭乱。所以我们会为每个bar都选择相同的随机颜色,而他们的高度还是各自随机产生。
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<Bar> 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<BarChart> {
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<Bar> {
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<BarChart> animation)
: animation = animation,
super(repaint: animation);
final Animation<BarChart> 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
把可用的宽度平均分配给每个bar,每个bar的柱体的宽度则占他们分得宽度的75%。
固定数量类别的柱状图动画
注意BarChart.lerp
是如何调用Bar.lerp
来动态生成bar列表的。对构成固定数量类别的柱状图来说,对它的复合值(译注:即bar列表)进行线性插值可以等同为对其各个组成部分(译注:即每个bar)分别进行线性插值,这点和对单个bar的多个属性进行线性插值是一样的(译注:即对每个bar属性分别线性插值)。
我们看到这里有一种模式。如果Dart 类的构造函数有多个入参,我们一般可以对每个入参分别插值,而对入参的入参也可以如此,这种模式可以一直嵌套下去:对仪表面板插值可以是对面板中的柱状图进行插值,柱状图的插值是对图中每个bar插值,bar的插值是对高度和颜色插值,颜色的插值是对其R,G,B和alpha值分别插值。最后,在这个递归过程的叶节点,就是对数字插值(译注:即color的 R, G, B, A值)。
倾向于用数学语言来表达的人会说线性插值在这种结构下是可交换的(译注:即满足交换律)。即对于复合值C(x,y)
lerp(C(x1,y1),C(x2,y2),t) == C(lerp(x1,x2,t),lerp(y1,y2,t))
译注:上式的意思是C函数和lerp函数可以互相交换执行顺序,结果仍然一样
我们看到,上式可以从两个分量,比如bar的高度和颜色,推广到任意多个分量,比如固定数量类别的n个bar。
不过有些时候这种美好情景会被打破。我们可能需要在实现两个不同构成的组合值之间的过渡动画。举个简单的例子,例如我们需要从一个展示五天工作日数据柱状图切换到一个还包括了周末数据的柱状图。
你可能已经想到好几种有针对性的解决办法,并拿着他们让你的UX设计师来选择了。这是一个有效的办法,不过我认为你们在讨论这些不同解决办法过程中值得牢记的是这些问题共有的基本结构:tween。记得第一部分我们说过:
译注:tween里的t值),Tween在类型T的取值空间里画出了一条路径。这个路径可以用Tween<T>来建模。
你和UX设计师一起要回答的核心问题是:有5个bar的柱状图和有7个bar的柱状图之间的插值是什么?一个显然的答案是6个bar的柱状图。不过我们还需要更多的中间插值才能让动画平滑。我们需要用不同的方式来画这个动画,不只是画等宽、均分的,正好是200px的柱状图。换句话说,范型T的取值空间应该是通用,泛化的。
在不同结构的复合值之间做线性插值,他们的取值空间应该有通用的结构,这个结构应包涵动画起始两端和他们所有的中间插值所需的属性。
我们可以分两步来做。首先我们把Bar
类变得更通用,给它添加X轴坐标和宽度两个属性。
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
支持有可变数量的bar的柱状图。 现在对于第i个bar表示数据序列中第i个值这样类型的数据,比如产品发布后第i天的销售额,我们的新柱状图也可以支持了。用程序员的计数法来说,这样的柱状图中从0到n-1的每个值都有对应的一个bar,但是不同的图之间的n可能是不同的。
考虑一个有5个bar和一个有7个bar的柱状图。第0到第4个bar的动画可以如我们先前所示,分别对其进行动画即可。而第5和第6个bar,在动画的另一端没有对应的部分。不过既然我们可以设置bar的位置和宽度了,那么我们用两个隐形的bar来作为他们在另一段的对应值。这样的视觉效果是,随着动画的进行,bar 5和bar6会从不可见的状态扩展到他们最终的位置和形状。反过来进行的话,他们会从当前的状态慢慢消失不见。
对组合值进行线性插值可转换为对其各个分量插值。如果对应的分量在插值的另一段不存在,那么可以用一个隐形的分量来代替它。
一般来说选择隐形分量可以有多种方法。比如我们友好的UX设计师决定我们的不可见bar的宽度为0,高度也为0,颜色和位置还是继承自其对应的可见bar的相同值。我们可以给Bar
类添加一个方法,用来创建一个可坍缩的bar实例。
class BarChart {
BarChart(this.bars);
final List<Bar> 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<BarChart> {
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),
);
}
}
把上面的代码集成到我们现有的app 中需要重新定义BarChart.empty
和BarChart.random
。空的柱状图现在理所当然的就可以包括0个bar,而随机的柱状图则可以包含随机数量的bar,每个bar都有同一个的随机选择的颜色和各自的随机高度。既然现在Bar
类定义了位置和宽度,BarChart.random
就需要来设置这些属性。把用图表的size入参是一个合理的选择,这样在BarChartPainter.paint
中的大部分计算可以不需要了。
隐形bar的插值动画
聪明的读者可能已经发现我们上面的BarChart.lerp
定义可能有潜在的低效问题。我们一次次的创建了可坍缩的bar实例,只是为了给Bar.lerp
函数提供入参,函数中的每一个t值都是如此。在每秒60帧的频率下,即使是很短的动画,这也会导致大量的Bar
实例被送到垃圾回收器。我们可以有如下几种办法:
- 可坍缩的
Bar
实例可以在Bar
类中只创建一次并重复使用,而不是每次调用collapsed
来生成。这种方法在这里可行,但是不能通用。 - 重用问题可以用
BarChartTween
来处理:在创建插值柱状图时就在BarChartTween
的构造函数中创建BarTween
的实例列表 _tweens:(i)=> _tweens [i] .lerp(t )
。这种方法破坏了一直以来lerp
方法为静态的惯例。在动画进行期间静态BarChart.lerp
不应存储tween列表。反而是BarChartTween
对象比较适合。 - 如果在
Bar.lerp
中使用了合适的条件逻辑,null
bar可用于表示坍缩的bar。这种方法灵活有效,不过需要注意避免对null
的解引用和误读其含义。在 Flutter SDK 中,静态lerp
方法使用null
作为动画终点很常见,一般把它认为是某种不可见元素,比如完全透明的颜色或大小为零的图像。举一个最基本的例子,除非动画的两个端点都是null
,lerpDouble
将把null
作为0。
//译注:lerpDouble代码如下:
double lerpDouble(num a, num b, double t) {
if (a == null && b == null)
return null;
a ??= 0.0;
b ??= 0.0;
return a + (b - a) * t;
}
使用null
表示法后,我们的代码如下:
class BarChart {
BarChart(this.bars);
final List<Bar> 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<BarChart> {
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 的?
syntax在这里很好用。注意,决定是否使用坍缩bar(而不是透明bar)的代码现在放在Bar.lerp
的条件逻辑中了。这就是我为什么使用先前看起来不那么高效的解决办法的原因。如果要在性能和可维护性中选一个,你的选择应该基于一定的考量。 (待续)
译注:我把这第二部分拆成了两部分,因为后面将说到如何用一个古老的算法来解决堆叠图表的动画并保持其语义,单独作为一篇会比较好。敬请期待。
第一部分:[译]Flutter动画图表浅入深出(一):Flutter、补间和动画基础
第三部分:[译]Flutter动画图表浅入深出(三):用古老算法解决复杂图表的终极问题