Flutter 必知必会系列 —— 随心所欲的自定义绘制 III
往期回眸:
前面我们讲了路径、图形和图文的绘制,并且在最开始的时候讲了 Flutter 的动画体系。这一篇文章我们就把路径的绘制和动画结合起来,并且对基本的 API 进行解释。
动画的含义
动画其实一系列静态的图像按着时间线连起来。比如下面的静态图片:
我们把静态图片按时间播放,就形成了下面的动图:
Flutter
中动画的描述就是 Animation
类,这个类是动画框架实现的主力,大多数动画 Widget
,包括我们自定义绘制的画笔,都接受一个 Animation
类型的对象作为其参数。想要动画的组件或者画笔就从这个对象中读取当前动画的值,然后响应这个值的变化。
前面我们知道了,AnimationController 可以通过 addListener
的方式实现动画值变化的监听,那我们就可以在方法体中调用 setState 来实现刷新。
示例代码如下:
class _PaintWidgetState extends State<PaintWidget>
with SingleTickerProviderStateMixin {
late AnimationController animationController;
late double offset;
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: Duration(seconds: 3),
);
animationController.addListener(() {
setState(() {
offset = animationController.value;//第一处
});
});
animationController.forward();
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.white,
child: CustomPaint(
painter: MyPainter(offset),//第二处
),
);
}
}
class MyPainter extends CustomPainter {
double value;
MyPainter(this.value);//第二处
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.color = Colors.red;
paint.strokeWidth = 2;
canvas.drawLine(Offset(20, 60), Offset(20 + 100 * value, 60), paint);//第三处
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
我们看上面的代码:
第一处:为动画增加了值变化的监听,并且监听的行为是 setState 刷新组件。
setState 的目的是调用 build 方法,用 offset
值构建新的 CustomPaint
第二处:将新的 offset
值传递进画笔,这个 offset 值就是动画值 0-1 的 double,代表了动画的进度
第三处:根据动画的进度来绘制一个直线,直线的起点是(20,60),端点是动画进度值的计算。
大家觉得上面可以动画么?对~,不可以。
为啥呢?因为 shouldRepaint
的返回值是 false,这个值表示了是否重新绘制。
因为我们每次是重新构建 CustomPaint
组件,但是其背后的 RenderCustomPaint
是不变的,RenderCustomPaint
会根据 shouldRepaint 的值来决定当 Widget 变化的时候是否发生绘制。
我们需要将 shouldRepaint
的返回值修改为 true。
实际的运行效果就是:
shouldRepaint = true | shouldRepaint = false |
---|---|
![]() | ![]() |
addListener + setState 的方式有什么优缺点吗?
优点:
实现简单
缺点:
效率较低,动画的实现依赖与 Widget 刷新联动的 RenderObject 的刷新
代码耦合,需要设置 shouldRepaint 是 true
除了这种手动设置联动的方式,Flutter 还提供了内置联动的方式!
动画无非就是响应变化,动画数值变化的时候,画笔就去画画。Flutter 中提供了两种通知变化的机制:Stream 和 Listenable。动画用的就是 Listenable,AnimationController
是 Listenable
的子类。
在画笔的构造方法中,画笔可以接受一个动画对象,如下图:
上面是画笔的构造方法,一个 _repaint 属性就关联起来了动画和绘制两个体系,关联如下:
在 RenderCustomPaint
的 attach
方法中,为 _painter
增加了监听的响应。attach 是 RenderObject
生命周期的第一个方法,所以相当于在声明的时候就增加了监听。
就是下面代码第一处的地方。
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_painter?.addListener(markNeedsPaint);//第一处
_foregroundPainter?.addListener(markNeedsPaint);
}
相当于 AnimationController
每次动画值变化的时候,都会调用到 RenderCustomPaint
的 markNeedsPaint
,而 markNeedsPaint
就是开始绘制的标记,注意它不是从布局开始,而是直接开始绘制。所以这样的效率更高。
而 _painter
的 addListener 实际就是 _repaint
的 addListener。也就是动画 AnimationController 的 addListener
上面就是 Flutter 中自定义绘制本身所支持绘制动画的机制
我们对 setState 的方式进行改造,如下:
class _PaintWidgetState extends State<PaintWidget>
with SingleTickerProviderStateMixin {
//...省略代码
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: Duration(seconds: 3),
);
//移出了addListener
animationController.forward();
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.white,
child: CustomPaint(
//第一处
painter: MyPainter(animationController),
),
);
}
}
class MyPainter extends CustomPainter {
Animation<double> animation;
//第一处
MyPainter(this.animation) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
//...省略代码
//第二处
canvas.drawLine(
Offset(20, 60), Offset(20 + 100 * animation.value, 60), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
//第三处
return false;
}
}
我们看改造的地方
第一处:MyPainter 增加了动画的构造方法,最重要的是要通过 super 传递给CustomPainter 的 repaint
第二处:动画的值在
Animation
的 value 中,所以使用 animation.value 来取出动画值第三处:即使
shouldRepaint
的值是 false,也会动画变化
下面我们介绍怎么给路径增加动画
绘制路径动画
绘制路径动画主要是两点:构造动画的路径
和 确定动画量与路径量的映射
构造动画路径
我们知道路径是一系列的子路径组成。比如,一条 100 的直线,可以用一个点分割成两条 50 的直线,并且使用 path 的 xxTo 就可以实现路径追加的效果。
这样的话,我们就可以用若干点分割一条完成的函数曲线,并把函数曲线添加到一条路径上, 点越密集就越接近真实的曲线。
添加即可以用曲线的方式追加,也可以使用直线的方式追加,区别在于直线的方式可能会有所失真。
曲线的方式 | 直线的方式 |
---|---|
![]() | ![]() |
我们以 ρ = 50 *(e^cosθ-2cos4θ+(sin(θ/12))^5 ) 图像为例,构建路径。
构建初始化坐标点
初始化的坐标点使用 Offset 表示,offset 是直角坐标系的描述,但是好多图像是极坐标的描述,所以需要一个转换的过程,这里简单说一下极坐标(高中知识已经还给老师了😂)。
极坐标 是指在平面内取一个定点 O,叫极点,引一条射线 Ox,叫做极轴。对于平面内任何一点 M
,用 ρ
表示线段 OM
的长度,θ
表示从 Ox 到 OM 的角度,ρ
叫做点 M 的极径,θ
叫做点 M 的极角,有序数对 (ρ,θ)
就叫点 M 的极坐标,如下图:
在极坐标下可以画出很多漂亮的图像:
极坐标下的圆 | 漂亮的玫瑰线 | 阿基米德线 |
---|---|---|
![]() | ![]() | ![]() |
对于设备来说,它不认识什么极坐标,它只知道 x 和 y,所以我们需要将极坐标转为设备可以认识的直角坐标。转换过程如下:
找到 A
点的极坐标 (ρ,θ)
,将坐标直接带入下面的公示:
比如圆的转换过程:
所以我们只需要使用极坐标表示x和y即可。
void _initPoints() {
points = [];
for (int i = 0; i < 360; i++) {
double thta = _convert(i);//第一处
double p = _calY(thta);//第二处
points.add(Offset(p * cos(thta), p * sin(thta)));//第三处
}
}
double _calY(double thta) {
return 50 * (pow(e, cos(x)) - 2 * cos(4 * x)) + pow(sin(x / 12), 5);//第二处
}
double _convert(int x) {
return pi / 180 * x;
}
第一处:将角度值转为弧度制,因为极坐标是在角度的世界绘制的,所以需要先转换。
第二处:将角度转为极坐标的
ρ
值,注意一下,这里给原图像夸大了 50 倍,是为了让图像更大。第三处:将极坐标转为了直角坐标,并添加到了坐标点集合中。注意一下,这里是从 0-360 度每个度数都添加一个坐标。
canvas.drawPoints(PointMode.points, points, paint);
绘制如下图:
上面就是准备好了坐标点,下面将坐标点相连。
坐标点相连成路径
上面我们讲了连接的方式有曲线和直线
直线的方式
Path path = Path();
for (int i = 1; i < points.length; i++) {
path.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(path, paint);
直接使用 lineTo 每一个点就可以。因为 Path
在 lineTo 到 坐标点 A
之后,下一段的 Path
的起点就是坐标点 A
。
lineTo 之后就连接成整个路径。效果如下:
曲线的方式
Path path = Path();
path.moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
double controllerX = (points[i].dx + points[i - 1].dx) / 2; //第一处
double controllerY = (points[i].dy + points[i - 1].dy) / 2; //第一处
path.quadraticBezierTo(
controllerX, controllerY, points[i].dx, points[i].dy); //第二处
}
canvas.drawPath(path, paint);
我们使用二阶贝塞尔曲线
的方式进行曲线连接,需要一个控制点和一个目标点。
目标点是曲线上的每一个点 控制点是前一个目标点和后一个目标点的中间点
在坐标点密集的情况下,看不出来二者的区别,当坐标点少一半的时候就会看出来。
确定动画量与路径量的映射
上面已经有了路径 Path
,现在就是把动画的时间与路径映射上。
映射之前我们先了解两个概念:路径度量 PathMetrics
和 路径正切
路径度量就是路径的轮廓和片段。比如我们下面的代码:
Path.lineTo
Path.moveTo
Path.lineTo
就会生成了两个度量,因为中间的 moveTo
打断了一段路径。
使用 PathMetric
可以对路径进行计算,比如长度、提取路径片段等等。
路径提取:
Path extractPath(double start, double end, {bool startWithMoveTo = true})
end 就是提取的终点
start 就是提取的起点
计算正切:
通过正切可以获取路径的位置
Tangent? getTangentForOffset(double distance)
distance 就是路径与起点的偏移量
void paint(Canvas canvas, Size size) {
//...省略代码
//路径度量
PathMetrics metrics = path.computeMetrics();
metrics.forEach((element) {
Path path = element.extractPath(0, element.length * animation.value);//第一处
canvas.drawPath(path, paint);//第二处
});
}
第一处的计算如下:
animation.value
是动画的度量,比如进度 50%
element.length
是路径的长度,比如总长度是 100
那么提取的路径是 0 到 50
第二处就是直接把子路径绘制下来
效果如下:
上面的效果就是按时间进度把路径完整的绘制,除了上面的提取路径之外,我们还可以通过正切把路径的坐标点拿下来。
Tangent? tangent = element.getTangentForOffset(
element.length * animation.value);//第一处
if (tangent != null) {
paint.style = PaintingStyle.fill;
paint.color = Colors.blue;
canvas.drawCircle(tangent.position, 3, paint);//第二处
}
第一处就是拿到了动画进度下的正切点,如下红色的点:
第二处的 position
就是实际的坐标点,在加上上面的绘制,效果如下:
注意看蓝色的小球球,带领着路径奔跑。
总结
动画的绘制就是一下两点:响应动画监听 和 确定动画路径。动画的监听不需要使用 setState
,只需要将动画对象通过画笔的构造方法赋值给 repaint 成员变量,这种方式直接作用在绘制阶段,效率更高。让动画沿着路径走,只需要确定路径度量和路径正切,然后用动画的进度取出相应的子路径和坐标点即可。
转载自:https://juejin.cn/post/7066707431971094542