Flutter中的Canvas绘图
https://zhuanlan.zhihu.com/p/424768534?utm_id=0
前言
在flutter日常开发中,有时候会遇到图表绘制相关的需求(如以下样式的表格):


或者是基础库无法实现的一些控件(如以下样式的进度条控件):

或者是一些图片遮罩的处理(如下图中用波浪线实现了对图片的裁剪功能):

这些较为复杂的情形都可以通过Canvas绘图来实现相关的效果。
Canvas简介
几乎所有的UI系统都会提供一个自绘UI的接口,狭义上的canvas一词指HTML中的canvas标签。canvas本身英文译为画布,广义上它在计算机领域里指的是所有使用画布来绘制UI这种方式。在Android和iOS它都有类似的实现。
既然有画布,那肯定也有画笔paint,canvas需要与paint一起使用。canvas内部封装了一些基本绘制的API,我们可以通过canvas结合paint来绘制各种自定义图形。在Flutter中提供了一个CustomPaint组件,它通过接收CustomPainter的子类来实现绘制业务。
const CustomPaint({
Key? key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget? child,
})
| painter | 背景画笔 |
| foregroundPainter | 前景画笔 |
| size | 当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。 |
| isComplex | 是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。 |
| willChange | 和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。 |
| child | 子节点 |
CustomPainter是一个抽象类,我们需要继承它来实现具体的绘制业务。其中,paint方法实现具体的绘制功能,而shouldRepaint则决定UI树重新build时是否有必要重绘。
class Painter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
// TODO: implement shouldRepaint
throw UnimplementedError();
}
}
Canvas基础知识
- 绘制坐标系

canvas是2d绘制引擎,上图是canvas绘制区域内的坐标系,圆点在左上角,水平向右为X轴正方向,垂直向下为Y轴正方向。
- Paint(画笔)
Paint paint = Paint()
..isAntiAlias = true
..color = Colors.pink
..blendMode = BlendMode.colorDodge
..strokeWidth = 10
..style = PaintingStyle.fill;
| isAntiAlias | 抗锯齿是否开启 |
| color | 画笔颜色 |
| blendMode | 混合模式 |
| strokeWidth | 画笔宽度 |
| style | 样式 PaintingStyle.fill 填充模式(默认值) PaintingStyle.stroke 线条模式 |
| strokeCap | 定义画笔端点形状 StrokeCap.butt 无形状(默认值) StrokeCap.round 圆形 StrokeCap.square 正方形 |
| strokeJoin | 定义线段交接时的形状 StrokeJoin.miter(默认值),当两条线段夹角小于30°时,StrokeJoin.mitter将会变成StrokeJoin.bevel StrokeJoin.bevel 斜面 StrokeJoin.round 圆角 |
strokeJoin.miter样式
strokeJoin.bevel样式
strokeJoin.round样式
- 绘制相关的api
(1)矩形-drawRect
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 10;
canvas.drawRect(const Rect.fromLTWH(0, 0, 200, 150), paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawRect(Rect rect, Paint paint)
| rect | 矩形区域 |
| paint | 画笔 |

(2)圆角矩形-drawRRect
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 10;
canvas.drawRRect(RRect.fromRectAndRadius(const Rect.fromLTWH(0, 0, 200, 150), const Radius.circular(10)), paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawRRect(RRect rrect, Paint paint)
| rrect | 圆角矩形区域 |
| paint | 画笔 |

(3)圆-drawCircle
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 10;
canvas.drawCircle(const Offset(100, 100), 75, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawCircle(Offset centerPoint, double radius, Paint paint)
| centerPoint | 中心点 |
| radius | 半径 |
| paint | 画笔 |

(4)椭圆-drawOval
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 10;
canvas.drawOval(const Rect.fromLTWH(0, 0, 150, 100), paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawOval(Rect rect, Paint paint)
| rect | 椭圆所在矩形区域 |
| paint | 画笔 |

(5)扇形-drawArc
import 'dart:math';
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.style = PaintingStyle.stroke;
paint.style = PaintingStyle.fill;
canvas.drawArc(const Rect.fromLTWH(0, 0, 100, 100), 0, pi * 0.3, true, paint);
canvas.drawArc(const Rect.fromLTWH(100, 0, 100, 100), 0, pi * 0.5, false, paint);
canvas.drawArc(const Rect.fromLTWH(0, 100, 150, 100), 0, pi * 0.3, true, paint);
canvas.drawArc(const Rect.fromLTWH(100, 100, 150, 100), 0, pi * 0.5, false, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
| rect | 扇形所在矩形区域 |
| startAngle | 开始角度 |
| sweepAngle | 增量角度 |
| useCenter | 是否使用中心点(下图中左侧图形均使用了中心点,右侧图形均没有使用中心点) |
| paint | 画笔 |

(6)点-drawPoints
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.color = Colors.black;
paint.strokeWidth = 10;
canvas.drawPoints(PointMode.points, const [Offset(0, 0), Offset(10, 50), Offset(80, 50), Offset(80, 100)], paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawPoints(PointMode pointMode, List<Offset> points, Paint paint)
| pointMode | 扇形所在矩形区域 |
| points | PointMode.points 点模式 PointMode.lines 线段模式 PointMode.polygon 连线模式 |
| paint | 画笔 |

PointMode.points 点模式

PointMode.lines 线段模式

PointMode.polygon 连线模式
(7)线-drawLine
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.color = Colors.black;
paint.strokeWidth = 10;
canvas.drawLine(const Offset(0, 0), const Offset(100, 100), paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawLine(Offset p1, Offset p2, Paint paint)
| p1 | 起始点 |
| p2 | 结束点 |
| paint | 画笔 |

(8)路径-drawPath
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
paint.color = Colors.black;
paint.strokeWidth = 10;
paint.style = PaintingStyle.stroke;
Path path = Path();
path.moveTo(0, 0);
path.lineTo(100, 0);
path.cubicTo(100, 0, 50, 50, 70, 90);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawPath(Path path, Paint paint)
| path | 路径 |
| paint | 画笔 |

(9)裁剪-clipPath
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Path path = Path();
path.moveTo(0, 100);
path.cubicTo(100, 50, 100, 150, 200, 100);
path.lineTo(200, 200);
path.lineTo(0, 200);
path.close();
canvas.clipPath(path);
Paint paint = Paint();
canvas.drawRect(const Rect.fromLTWH(0, 0, 200, 200), paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void clipPath(Path path, {bool doAntiAlias = true})
| path | 路径 |
| doAntiAlias | 是否开启抗锯齿(默认值为true) |

(10)文本-drawParagraph
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final textStyle = ui.TextStyle(color: Colors.black, fontSize: 30);
final paragraphStyle = ui.ParagraphStyle(textDirection: TextDirection.ltr);
final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle);
paragraphBuilder.pushStyle(textStyle);
paragraphBuilder.addText('Hello, world');
const constraints = ui.ParagraphConstraints(width: 300);
final paragraph = paragraphBuilder.build();
paragraph.layout(constraints);
canvas.drawParagraph(paragraph, const Offset(0, 0));
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void drawParagraph(Paragraph paragraph, Offset offset)
| paragraph | 段落对象 |
| offset | 坐标值 |

Canvas绘制表格

表格的绘制实际上是多种绘制api的组合使用,如上图所示,绘制时需要用到矩形、线以及文本的绘制。绘制出上图的效果需要对绘制的步骤进行一定的拆分,分为5步:
- 绘制最外层矩形框(矩形的stroke模式)
- 绘制交叉线(线的stroke模式)
- 绘制填充颜色的矩形条(矩形的fill模式)
- 绘制带外边框颜色的矩形条(矩形的stroke模式)
- 绘制文本,并在最底下绘制一条黑线

以下是绘制表格的完整代码:
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class DrawChart extends StatelessWidget {
const DrawChart({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('绘制表格')),
backgroundColor: Colors.white,
body: Center(
child: SizedBox(
width: 400,
height: 400,
child: CustomPaint(painter: DrawPainter()),
),
),
);
}
}
class DrawPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 绘制外边框
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.grey.withOpacity(0.1));
// 绘制线条
double offset = 80;
for (int i = 0; i < 4; i++) {
drawVerticalLine(canvas, size, offsetX: offset, color: Colors.grey.withOpacity(0.1), strokeWidth: 1);
drawHorizontalLine(canvas, size, offsetY: offset, color: Colors.grey.withOpacity(0.1), strokeWidth: 1);
offset += 80;
}
for (int i = 0; i < 5; i++) {
// 随机高度
var height = Random().nextInt(size.height.toInt()).toDouble();
// 随机颜色
var color