这是我参与2022首次更文挑战的第13天,活动详情查看:2022首次更文挑战
往期回眸:
Flutter 必知必会系列 —— 随心所欲的自定义绘制 I
前面一期的文章,我们介绍了自定义绘制的舞台,并且示例了基本的绘制。这一篇我们使用CustomPainter 来绘制路径、图片和文本。
绘制路径 Path
Path
是 Flutter 中的绘制路径,一条完整的路径由若干子路径和一个初始点组成。
从类型来看,子路径可以是直线、圆和曲线。从状态来看,子路径可以是开放的,也可以是闭合的,闭合的子路径会根据 fillType
形成一个多边形。
初始点是指最开始添加子路径的点,之后会变成添加的子路径的末端点,也就是说,它是动态的开始端点。
我们绘制路径的时候,可以调用画布的 API:Canvas.drawPath
,和 Canvas 的直接画不同,操作 Path 更加灵活,并且复杂的图形绘制更加简单,比如贝塞尔曲线。
Path 支持的方法列表如下:
方法名 | 含义 |
---|---|
moveTo、relativeMoveTo | 移动路径的开始端点到指定的 x、y,后者是相对值 |
lineTo、relativeLineTo | 从开始端点绘制直线到指定的x、y,后者是相对值 |
quadraticBezierTo、relativeQuadraticBezierTo | 从开始端点绘制二阶贝塞尔曲线,末尾端点是(x2,y2),控制点是(x1,y1),后者是相对值 |
cubicTo、relativeCubicTo | 从开始端点绘制三阶贝塞尔曲线,末尾端点是(x3,y3),控制点是(x1,y1)和(x2,y2),后者是相对值 |
arcTo | 从开始端点绘制圆弧 |
arcToPoint | 从开始点到目标点绘制一个曲线, |
relativeArcToPoint | 从开始点到目标点绘制一个曲线,坐标是相对值 |
addRect、addOval、addArc、addPolygon、addRRect | 从开始点绘制一个矩形、椭圆、圆弧、多边形、圆角矩形 |
addPath | 从开始点拼接一个新的路径 |
extendWithPath | 路径的当前段扩展为给定路径的第一个段 |
close、reset、contains | 闭合路径、路径重置、点是否在路径内 |
shift、transform | 使用给定的点来移动路径,使用给定的矩阵来转换路径 |
getBounds、computeMetrics | 获得路径边界、创建路径的描述 |
点的移动和线的绘制
路径是从起点开始绘制的,默认是从画布的原点 (0,0)
。可以通过 moveTo
和 relativeMoveTo
来修改起点。
绝对坐标好理解,这里说一下相对坐标。
A 点坐标(20,20),B 点坐标(40,40)
那么 B 相对 A 的坐标就是(20,20),A 相对 B 的坐标就是 (-20,-20)
相对坐标的正负,依然遵循 下正上负,右正左负的原则
示例代码:
@override
void paint(Canvas canvas, Size size) {
Path path = Path();
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.color = Colors.red;
paint.strokeWidth = 5;
path.lineTo(100, 100); //第一处
path.moveTo(size.width/2, size.height/2);//第二处
path.relativeLineTo(100, 100);//第三处
path.relativeLineTo(-100, 100);//第四处
path.relativeLineTo(-100, -100);//第五处
canvas.drawPath(path, paint); //第六处
}
运行效果:
第一处的代码,直接 lineTo 到 (100,100)
,所以就是左上角的效果,从原点到 (100, 100) 的一条直线。
第二处,moveTo(size.width/2, size.height/2),我们将组件的大小已经设置为了屏幕大小,所以 size.width/2, size.height/2 就是屏幕的中心点 —— A 点
。后面的线都是从 A
开始画。
第三处,relativeLineTo(100, 100),翻译过来就是, X 轴
是 A 点向右 100
,Y 轴
是 A 点向下 100
,所以 B
坐标就是(size.width/2 + 100, size.height/2 + 100)
第四处,relativeLineTo(-100, 100),翻译过来就是, X 轴是 B 点向左 100,Y 轴是 B 点向下 100,所以 C 坐标就是 (size.width/2, size.height/2+200)
第五处,relativeLineTo(-100, -100),翻译过来就是, X 轴是 C 点向左 100,Y轴是 B 点向上 100,所以 D 坐标就是 (size.width/2-100, size.height/2+100)
第六处,canvas.drawPath 就是用画笔将路径画出来
绘制贝塞尔曲线
贝塞尔曲线 是应用于二维图形应用程序的数学曲线,线条的绘制由起始点、终止点(也称锚点)和控制点来实现。并且控制点决定了一条路径的弯曲轨迹。
根据控制点的个数,贝塞尔曲线被分为:一阶贝塞尔曲线(0 个控制点)、二阶贝塞尔曲线(1 个控制点)、三阶贝塞尔曲线(2 个控制点)、N 阶贝塞尔曲线(n - 1 个控制点)。
Flutter 中现成的 API 是:二阶quadraticBezierTo 和 三阶cubicTo
以二阶贝塞尔曲线为例,模仿轨迹路径,起始点:P0 ; 控制点:P1 ; 终止点:P2,如下图:
连接 P0P1线 和 P1P2 线
在 P0P1 线上找到点 A,在 P1P2 线上找到点 B,使得 P0A/AP1 = P1B/BP2
连接 AB,在 AB上找到点 X,X 点满足:AX/XB = P0A/AP1 = P1B/BP2
找出所有满足公式:AX/XB = P0A/AP1 = P1B/BP2 的X点。(从P0 到 P2 的红色曲线点为所有X点的连线)这条由所有X点组成的连线 即为 贝塞尔曲线。
二阶贝塞尔曲线动画 | 三阶贝塞尔曲线动画 |
---|---|
贝塞尔曲线能干啥呢?就是让线条更好看,更平滑。比如像电商的加入购物车动画、QQ 的拖拽动画等等。
在 Flutter 中只要我们控制好起止点和控制点就可以了。
绘制二阶贝塞尔曲线
quadraticBezierTo(double x1, double y1, double x2, double y2)
这是二阶贝塞尔曲线, (x1, y1) 就是控制点,(x2, y2) 是端点。
示例代码
@override
void paint(Canvas canvas, Size size) {
Path path = Path();
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.color = Colors.red;
paint.strokeWidth = 3;
paint.color = Colors.blue;
path.moveTo(0, 400); //第一处
path.quadraticBezierTo(200, 0, size.width, 400);
path.moveTo(0, 400); //第二处
path.quadraticBezierTo(200, 100, size.width, 400);
canvas.drawPath(path, paint);
}
第一处和第二处的代码都是为了保证 Path 的起点是 (0, 400)
。
画出的效果如下:
两条曲线的起止点一样,控制点不一样,效果就有所不同。
绘制三阶贝塞尔曲线
cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
这是二阶贝塞尔曲线,(x1,y1)和 (x2,y2) 就是控制点,**(x3,y3)**是端点。
示例代码:
@override
void paint(Canvas canvas, Size size) {
Path path = Path();
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.color = Colors.red;
paint.strokeWidth = 3;
// 贝塞尔曲线
Offset p0 = Offset(0, 400);
Offset p1 = Offset(80, 80);
Offset p2 = Offset(180, 120);
Offset p3 = Offset(size.width, 400);
paint.color = Colors.blue;
path.moveTo(p0.dx, p0.dy);
path.cubicTo(p1.dx, p1.dy, p2.dx, p2.dy, p3.dx, p3.dy);
canvas.drawPath(path, paint);
// 控制线
Path linePath = Path();
paint.color = Colors.grey;
linePath.moveTo(p0.dx, p0.dy);
linePath.lineTo(p1.dx, p1.dy);
linePath.lineTo(p2.dx, p2.dy);
linePath.lineTo(p3.dx, p3.dy);
canvas.drawPath(linePath, paint);
}
p0
是起点,p1
和 p2
是控制点,p3
是末端点。
为了让大家的效果更好,下面绘制了点的控制线。效果如下:
这么看贝塞尔曲线的话,确实有点单薄,其实贝塞尔曲线和动画结合起来,就非常 nice 了。
后面的章节我们看结合的效果。
绘制形状
绘制形状和 canvas 非常像,我们就简单的做一个效果的介绍。
示例代码:
@override
void paint(Canvas canvas, Size size) {
canvas.translate(0, size.height/2);
Path path = Path();
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.color = Colors.red;
paint.strokeWidth = 3;
Rect rect = Rect.fromPoints(Offset(100, 100), Offset(160, 140));
path
..relativeLineTo(100, 100)
..addOval(rect)//绘制椭圆
..relativeLineTo(100, -100)
..addArc(rect.translate(100 + 60.0, -100), 0, pi)//绘制180度的圆弧
..addRect(rect.translate(100 + 120.0, -120))//绘制矩形
..relativeLineTo(-100, -100)
..addRRect(RRect.fromRectXY(rect.translate(60.0, -220), 5, 5))//绘制圆角矩形
;
canvas.drawPath(path, paint);
}
示例效果:
除了这些现成的形状之外,Path 还支持添加自定义的多边形
addPolygon(List points, bool close)
points
是多边形的顶点,类型是数组,数组中一个元素是 点,两个元素是 一条直线,两个元素以上是 多边形
close
是指是否闭合多边形
@override
void paint(Canvas canvas, Size size) {
canvas.translate(0, size.height / 2);
Path path = Path();
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.color = Colors.red;
paint.strokeWidth = 3;
Rect rect = Rect.fromPoints(Offset(100, 100), Offset(160, 140));
// 闭合多边形
path.addPolygon([
rect.topLeft,
rect.topRight,
rect.bottomRight,
rect.bottomLeft,
], true);
// 不闭合多边形
path.addPolygon([
rect.topLeft + Offset(100, 0),
rect.topRight + Offset(100, 0),
rect.bottomRight + Offset(100, 0),
rect.bottomLeft + Offset(100, 0),
], false);
canvas.drawPath(path, paint);
}
运行效果:
左侧是闭合的矩形,右侧是开放的矩形。
绘制图片
绘制图片主要有三个方法:
drawImage(Image image, Offset offset, Paint paint) //直接绘制图片
drawImageRect(Image image, Rect src, Rect dst, Paint paint) //绘制图片某一部分
drawImageNine(Image image, Rect center, Rect dst, Paint paint) //绘制点9图片
绘制前的准备
图片的绘制肯先有样板图,样板图就是图片的字节形式。
首先,将样板图片添加到 yaml 文件中
assets:
- assets/lan.png
其次,读取资源文件
rootBundle.load("assets/lan.png").then((value) {
Uint8List bytes = value.buffer.asUint8List(value.offsetInBytes, value.lengthInBytes);
});
读出的数据是 ByteData
,将 ByteData
数据转为 Flutter 图片库可以识别的 Uint8List
最后,将字节数据转为图片
rootBundle.load("assets/lan.png").then((value) async {
Uint8List bytes =
value.buffer.asUint8List(value.offsetInBytes, value.lengthInBytes);
image = (await decodeImageFromList(bytes));
});
这里我将图片 image 变量声明成了顶层变量以便后面的使用,注意一下,image 的类型是 ui库
里面的 Image
,而不是组件库中的 Image
,所以导包的时候需要增加前缀 import 'dart:ui' as ui
绘制图片
drawImage(Image image, Offset offset, Paint paint)
image 就是我们的图片数据
offset 就是绘制的位置,相对于原点
paint 就是绘制的画笔
示例代码:
@override
void paint(Canvas canvas, Size size) {
canvas.translate(0, 44);
Paint paint = Paint();
canvas.drawImage(image, Offset.zero, paint);
}
实际的效果:
如果这就完了,那么直接使用 Image 组件不就行了么?
哈哈😄,重点就是在paint上,可以使用画笔对图片的色彩和形状进行操作。
画笔中对图片的色彩和形状操作主要是 imageFilter (图片过滤器)
、colorFilter(颜色过滤器)
和 maskFilter(遮罩过滤器)
。
下面我们就以此来看。
ImageFilter —— 图片过滤器
ImageFilter 主要是对图片进行矩阵的变换和高斯模糊,矩阵变换主要有位移,斜切,缩放等等。
矩阵变换
示例代码
@override
void paint(Canvas canvas, Size size) {
canvas.translate(0, 44);
Paint paint = Paint();
paint.imageFilter =
ui.ImageFilter.matrix(Matrix4.diagonal3Values(0.5,0.5,1).storage);//第一处
canvas.drawImage(image, Offset.zero, paint);
}
第一处声明了一个缩小 2 倍的矩阵。Matrix4 是 Flutter 的矩阵类,大家可以看其API,矩阵的操作它都支持,比如缩放、位移、行列式操作等等。
具体的矩阵计算是在 Skia
代码中,这里就不一一计算了。下面是 Engine 对 Skia 的调用。
void ImageFilter::initMatrix(const tonic::Float64List& matrix4,
int filterQualityIndex) {
auto sampling = ImageFilter::SamplingFromIndex(filterQualityIndex);
filter_ =
SkImageFilters::MatrixTransform(ToSkMatrix(matrix4), sampling, nullptr);
}
具体的运行效果就是懒总变小了。
高斯模糊
示例代码
@override
void paint(Canvas canvas, Size size) {
canvas.translate(0, 44);
Paint paint = Paint();
paint.imageFilter = ui.ImageFilter.blur(sigmaX: 1, sigmaY: 1);//第一处
canvas.drawImage(image, Offset.zero, paint);
paint.imageFilter = ui.ImageFilter.blur(sigmaX: 2, sigmaY: 2);//第二处
canvas.drawImage(image, Offset(0,310), paint);
}
第一处和第二处就是添加模糊的代码,即 blur 函数,sigmaX
和 sigmaY
是模糊半径,数值越大,模糊程度越高。
运行效果如下:
相对于上面的懒总,下面的懒总更加模糊。
ColorFilter —— 颜色过滤器
颜色过滤器对颜色的变化,主要有两种方式:颜色混合和矩阵运算。颜色混合主要是用源和目标进行运算,矩阵运算主要是用行列式计算颜色通道。下面我们简单的举两个例子,因为这种颜色的运算真的不常见,有这功夫还不如让 UI 切图呢 😂
颜色混合
我们都知道混合,就是将源和目标可进行混合,然后显示混合的的结果。具体的API是
ColorFilter.mode(Color color, BlendMode blendMode)
color 属性
是源,想要绘制的图片是目标
BlendMode
将混合算法以枚举的形式提供出来,共有29个。比如 BlendMode.src 就是仅仅显示源,BlendMode.dst 仅仅显示目标
用红色作为源,懒总作为目标,示例代码如下:
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.colorFilter = ui.ColorFilter.mode(Colors.red, blendMode);
canvas.drawImage(image, Offset.zero, paint);
}
运行的效果如下:
比较的逗的是 overlay ,把懒羊羊换肤成了沸羊羊。上面的图片不够清晰,我们放一张高清的图片
我们先看 Overlay 模式的定义:
Overlay combines Multiply and Screen blend modes.[3] Where the base layer is light, the top layer becomes lighter; where the base layer is dark, the top becomes darker; where the base layer is mid grey, the top is unaffected. An overlay with the same picture looks like an S-curve.
where a is the base layer value and b is the top layer value.
Depending on the value a of the base layer, one gets a linear interpolation between black (a=0), the top layer (a=0.5), and white (a=1).
翻译过来就是:
Overlay 模式结合了 Multiply
和 Screen
模式,在 Overlay
模式下,源图层越亮的地方,目标图层就越亮,源图层越暗的地方,目标图层就越暗。如果源图层是中灰,那么目标图层就不受影响。Overlay 模式
的曲线就像一个 S 曲线一样。
这个公式里面,a 是源图层,b 是目标图层。根据 源图层 a 的值,目标图层的值会有一个线性的插值。
这就是公式的介绍,离我们好像很远。我们就一步步的计算。
首先是 a 和 b 的值是谁?
a 是源图层的值,b 是目标图层的值,对应到我们的懒羊羊案例,a 就是红色图片
,b 就是懒羊羊图片
。
那两个图片怎么计算呢?对,就是使用组成图片像素的 R、G、B通道 进行计算。
我们知道 RGB 的数值范围是0-255,而在数值的计算中,需要把 RGB对应的值压缩到 0-1之间。
我们案例中红色的 RGB 是 244 、67 、54,转成 0-1 之间的值就是:0.95686、0.26274、0.21176。
上图的懒羊羊的皮肤,RGB 颜色是 254、207、177,转成 0-1 之间的值就是:0.99607、0.81176、0.69411
红色是 a,懒羊羊是b,代入上面的公式:
R 的值就是 0.99996,换算成整数的 int 值就是 255
G 的值就是 0.42656,换算成整数的 int 值就是 109
B 的值就是 0.29396,换算成整数的 int 值就是 75
我们在 PS 中看生成的效果:
就是这三个数值,这就是颜色混合。
这是从数学的角度进行计算,这些混合模式设计师肯定知道其背后的含义和场景。
颜色矩阵
除了上面的颜色混合,还可以用颜色矩阵进行乘法的计算,说实话,我的矩阵知识已经还给数学老师了😳。
具体的api是
ColorFilter.matrix(List matrix)
图片中像素的新颜色是 matrix 与像素原有颜色的乘积。
颜色怎么乘积呢?还是用通道乘积!!
matrix 的矩阵需要是 5 * 4
原有像素的表示是 1 * 5
形成的结果就是:1 * 4
计算过程如下:
比如颜色反色的计算
const ColorFilter invert = ui.ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]);
计算的结果就是:
R` = 255 - R
G` = 255 - G
B` = 255 - B
A` = A
所以结果就是颜色取反色,效果如下:
绘制 .9 图片
Flutter 会用两条水平线和两条垂直线将图片分成九分,分割之后,再把图片放到一个目标区域。原图和目标区域的大小经常不一致,.9图片 就是应对这种不一致的一种方法。
方法原理: 四个角的区域是不变的,然后伸缩剩下的五个区域来达到总体的平衡。
我们看 API:
drawImageNine(Image image, Rect center, Rect dst, Paint paint)
center 是一个矩形,矩形就是两水平两垂直的线,所以这个参数决定了线是什么。 因为是在图片上上画参考线,所以这个矩形的原点就是图片的左上角。
dst 也是矩形,它就是要绘制的目标区域的描述。 因为它是要绘制的区域,所以这个矩形的原点就是屏幕的左上角。
我们举个例子:
中间5 的区域就是 center
的描述,它的延伸线将懒羊羊分成了九块。1、3、7、9 是不变的,2、4、5、6、8是变化的。
我将图片放在了 PS 里面,可以更好的看像素位置,原图的大小是 100*100,鼻子的位置左上角大概就是 55*57,鼻子的宽高大概是 10。
我们看示例代码:
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
// 直接绘制原图 100*100
canvas.drawImage(image, Offset.zero, paint);
// 绘制放大一倍的.9图片
canvas.drawImageNine(image, Rect.fromLTWH(55, 57, 10, 10),
Rect.fromLTWH(100, 100, 200, 200), paint);
// 绘制缩小一倍的.9图片
canvas.drawImageNine(image, Rect.fromLTWH(55, 57, 10, 10),
Rect.fromLTWH(0, 300, 50, 50), paint);
}
第三个参数决定了目标区域的位置和大小,
运行效果:
我们可以看到懒羊羊的脸部区域变大和变小了,比较明显的是鼻子。
上面就是 .9图片的绘制。
绘制图片区域
绘制图片区域就是将原图的一部分,画到另外一个区域。
drawImageRect(Image image, Rect src, Rect dst, Paint paint)
同样的道理,
src 是描述原图区域的矩形,所以坐标的原点是图片的左上角
dst 是描述目标区域的矩形,所以坐标的原点是屏幕的原点
我们还拿懒羊羊的鼻子做示例,
鼻子位置的左上角大概就是 55*57,鼻子的宽高大概是10,我们将它移动到另外一个位置。
示例代码:
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
// 绘制原图
canvas.drawImage(image, Offset.zero, paint);
// 在200*0 的位置绘制鼻子
canvas.drawImageRect(image, Rect.fromLTWH(55, 57, 10, 10),
Rect.fromLTWH(200, 0, 10, 10), paint);
// 在200*50 的位置绘制鼻子
canvas.drawImageRect(image, Rect.fromLTWH(55, 57, 10, 10),
Rect.fromLTWH(200, 50, 20, 20), paint);
}
运行的效果:
注意看,除了位置的变化之外,鼻子的大小也会随着目标区域的缩放而缩放。
这就是区域绘制
绘制文字
相对于图片的绘制,文字的绘制就简单了点。基本就是控制三个变量:位置、样式、内容
具体的绘制API 是:
canvas.drawParagraph(paragraph, offset)
paragraph 是绘制的内容,包含了样式
offset 是绘制的位置
由于绘制的内容可能会比较复杂,所以 Flutter 使用了 Builder
的模式,也就是通过 ParagraphBuilder
来构造出 paragraph 。
示例代码:
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
///绘制文本
ui.ParagraphStyle style = ui.ParagraphStyle(
fontSize: 14,
);
ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(style);
paragraphBuilder.pushStyle(ui.TextStyle(
color: Colors.blue,
));
paragraphBuilder.addText("青青草原一片天,谁见懒总不递烟");
ui.Paragraph paragraph = paragraphBuilder.build();
paragraph.layout(ui.ParagraphConstraints(width: 200));
canvas.drawParagraph(paragraph, Offset.zero);
}
运行效果:
文字绘制需要注意一点:绘制之前一定要 layout!!!!。
总结
上面介绍了路径和图文的绘制,相对来说,路径的绘制更加常用,路径可以和动画结合起来,做出一些更炫的效果。下一篇我们介绍怎么和动画联动。图文的绘制我们基本很少用到,不过里面一些概念还是需要知道的,比如颜色混合、.9 缩放等等。数学确实很重要,但我确实不会~~~~。