Flutter 必知必会系列 —— 随心所欲的自定义绘制 II

1,802 阅读16分钟

这是我参与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) 。可以通过 moveTorelativeMoveTo 来修改起点。

绝对坐标好理解,这里说一下相对坐标。
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); //第六处
}

运行效果:

moveTo.jpeg

第一处的代码,直接 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 点向右 100Y 轴是 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,如下图:

贝塞尔曲线过程.png

连接 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)

画出的效果如下:

二阶贝塞尔曲线.png

两条曲线的起止点一样,控制点不一样,效果就有所不同。

绘制三阶贝塞尔曲线

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 是起点,p1p2 是控制点,p3 是末端点。

为了让大家的效果更好,下面绘制了点的控制线。效果如下:

image-20220208204401435.png

这么看贝塞尔曲线的话,确实有点单薄,其实贝塞尔曲线和动画结合起来,就非常 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);
}

示例效果:

image-20220209103040462.png

除了这些现成的形状之外,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);
}

运行效果:

image-20220209104136218.png

左侧是闭合的矩形,右侧是开放的矩形。

绘制图片

绘制图片主要有三个方法:

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-20220209123419411.png

如果这就完了,那么直接使用 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);
}

具体的运行效果就是懒总变小了。

image-20220209133059429.png

高斯模糊

示例代码

  @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 函数,sigmaXsigmaY 是模糊半径,数值越大,模糊程度越高。

运行效果如下:

image-20220209192041719.png

相对于上面的懒总,下面的懒总更加模糊。

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);
}
​

运行的效果如下:

颜色混合.png

比较的逗的是 overlay ,把懒羊羊换肤成了沸羊羊。上面的图片不够清晰,我们放一张高清的图片

高清懒羊羊.png

我们先看 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.

公式.png 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 模式结合了 MultiplyScreen 模式,在 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。

懒羊羊源皮肤.png

上图的懒羊羊的皮肤,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 中看生成的效果:

image-20220210141249452.png

就是这三个数值,这就是颜色混合

这是从数学的角度进行计算,这些混合模式设计师肯定知道其背后的含义和场景。

颜色矩阵

除了上面的颜色混合,还可以用颜色矩阵进行乘法的计算,说实话,我的矩阵知识已经还给数学老师了😳。

具体的api是

ColorFilter.matrix(List matrix)

图片中像素的新颜色是 matrix 与像素原有颜色的乘积
颜色怎么乘积呢?还是用通道乘积!!

matrix 的矩阵需要是 5 * 4
原有像素的表示是 1 * 5
形成的结果就是:1 * 4

计算过程如下:

矩阵计算.png

比如颜色反色的计算

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

所以结果就是颜色取反色,效果如下:

image-20220210145555791.png

绘制 .9 图片

Flutter 会用两条水平线和两条垂直线将图片分成九分,分割之后,再把图片放到一个目标区域。原图和目标区域的大小经常不一致,.9图片 就是应对这种不一致的一种方法。

方法原理: 四个角的区域是不变的,然后伸缩剩下的五个区域来达到总体的平衡。

我们看 API:

drawImageNine(Image image, Rect center, Rect dst, Paint paint)

center 是一个矩形,矩形就是两水平两垂直的线,所以这个参数决定了线是什么。 因为是在图片上上画参考线,所以这个矩形的原点就是图片的左上角。

dst 也是矩形,它就是要绘制的目标区域的描述。 因为它是要绘制的区域,所以这个矩形的原点就是屏幕的左上角。

我们举个例子:

9图片分区域.png

中间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图片.png

我们可以看到懒羊羊的脸部区域变大和变小了,比较明显的是鼻子。

上面就是 .9图片的绘制。

绘制图片区域

绘制图片区域就是将原图的一部分,画到另外一个区域。

drawImageRect(Image image, Rect src, Rect dst, Paint paint)

同样的道理,

src 是描述原图区域的矩形,所以坐标的原点是图片的左上角
dst 是描述目标区域的矩形,所以坐标的原点是屏幕的原点

我们还拿懒羊羊的鼻子做示例,

image-20220210204035830.png

鼻子位置的左上角大概就是 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);
}

运行的效果:

image-20220210204519450.png

注意看,除了位置的变化之外,鼻子的大小也会随着目标区域的缩放而缩放。

这就是区域绘制

绘制文字

相对于图片的绘制,文字的绘制就简单了点。基本就是控制三个变量:位置、样式、内容

具体的绘制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);
}

运行效果:

文本绘制.png

文字绘制需要注意一点:绘制之前一定要 layout!!!!。

总结

上面介绍了路径和图文的绘制,相对来说,路径的绘制更加常用,路径可以和动画结合起来,做出一些更炫的效果。下一篇我们介绍怎么和动画联动。图文的绘制我们基本很少用到,不过里面一些概念还是需要知道的,比如颜色混合、.9 缩放等等。数学确实很重要,但我确实不会~~~~。