Flutter-CustomPaint与Canvas
对于一些复杂或不规则的UI,无法通过组合其他组件的方式来实现,就需要自己绘制UI。几乎所有的UI系统都会提供一个自绘UI的接口,比如iOS的CoreGraphics,Flutter 中提供一块2D画布Canvas,Canvas内部封装了一些基本的绘制API,开发者可以通过Canvas绘制各种自定义图形,在Flutter中提供了yigeCunstomPaint组件,它可以结合画笔CustomPainter来实现自定义图形绘制。
CustomPaint
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包裹CustomPainter实现。
- isComplex:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
- willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。
绘制时需要提供前景或背景画笔,两者也可以同时提供。画笔需要继承CustomPainter类,在画笔类中实现真正的绘制逻辑。
绘制边界RepaintBoundary
如果CustomPainter有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary子组件的绘制将独立于父组件的绘制,RepaintBoundary会隔离其子节点和CustomPaint本身的绘制边界。
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
child: RepaintBoundary(child:...)),
)
CustomPainter与Canvas
CustomPainter中定义了一个虚函数paint
void paint(Canvas canvas, Size size);
paint有两个参数:
-
Canvas:一个画布,包括各种绘制方法: | API 名称 | 功能 | | -- | -- | | drawLine | 画线 | | drawPoint | 画点 | | drawPath | 画路径 | | drawImage | 画图像 | | drawRect | 画矩形 | | drawCircle | 画圆 | | drawOval | 画椭圆 | | drawArc | 画弧 |
-
Size:当前绘制区域大小
画笔Paint
画笔主要是提供各种属性:如颜色、粗细、样式等。
var paint = Paint() //创建一个画笔并配置其属性
..isAntiAlias = true //是否抗锯齿
..style = PaintingStyle.fill //画笔样式:填充
..color=Color(0x77cdb175);//画笔颜色
示例:
- 绘制棋盘、棋子
class SSLPainter extends CustomPainter{
@override
// TODO: implement painter
void paint(Canvas canvas, Size size) {
var rect = Offset.zero & size;
drawChessboard(canvas, rect);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
// TODO: implement shouldRepaint
return false;
}
//绘制棋盘
void drawChessboard(Canvas canvas, Rect rect){
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill
..color = const Color(0xFFDCC48C);
//绘制背景
canvas.drawRect(rect, paint);
//绘制网格
paint
..style = PaintingStyle.stroke
..color = Colors.black38
..strokeWidth = 1.0;
//绘制横线
for (int i = 0; i <= 15; i ++){
double dy = rect.top + rect.height /15 * i;
canvas.drawLine(Offset(rect.left, dy), Offset(rect.right, dy), paint);
}
//绘制竖线
for (int i = 0; i <= 15; i++){
double dx = rect.left + rect.width / 15 * i;
canvas.drawLine(Offset(dx, rect.top), Offset(dx, rect.bottom), paint);
}
}
}
class SSLForegroundPainter extends CustomPainter{
SSLForegroundPainter({required this.points});
Set<Offset> points;
@override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
var rect = Offset.zero & size;
drawPieces(canvas, rect);
}
void drawPieces(Canvas canvas, Rect rect){
double width = rect.width / 15;
double height = rect.height / 15;
if (points.isNotEmpty){
var paint = Paint();
for (int i = 0; i < points.length; i ++){
Offset point = points.elementAt(i);
int x = point.dx ~/ width;
double xC = point.dx % width;
if (xC >= width/2){
x += 1;
}
int y = point.dy ~/ height;
double yC = point.dy % height;
if (yC >= height/2){
y += 1;
}
if (i%2 == 0){
//画黑子画笔
paint.style = PaintingStyle.fill;
paint.color = Colors.black;
}else{
//绘制白子
paint.color = Colors.white;
}
// debugPrint("ssl touch pieces $x, $y, $width,$height\n");
canvas.drawCircle(Offset(x*width , y*height ), min(width/2.0, height/2.0), paint);
}
}
}
@override
bool shouldRepaint(covariant SSLForegroundPainter oldDelegate) {
// TODO: implement shouldRepaint
return true;
}
}
绘制性能
绘制是比较昂贵的操作,所以在实现自绘控件时应该考虑到性能开销。
- 尽可能的利用好shouldRepaint返回值,在UI树重新build时,空间在绘制前都会调用该方法以确认是否必要重绘;假如绘制的UI不依赖外部状态,即外部状态的改变不会影响到自绘的UI外观,那么应该返回false;如果依赖外部状态,那么应该在shouldRepaint中判断依赖的状态是否改变,如果改变则应该返回true来重绘,反之则应该返回false不需要重绘。
- 绘制尽可能多的分层:在绘制五子棋示例中,将棋盘和棋子分开绘制。因为棋盘只需要绘制一次,但是棋子可能绘制多次,如果放一起,每次都重新绘制,这是没必要的。优化时可以将棋盘单独抽为一个组件,并设置shouldRepaint为false,然后将棋盘组件作为背景,然后将棋子放到另外一个组件中,每次落子只需要绘制棋子即可。减少不必要绘制。
防止意外重绘
在上例中添加一个Button,点击后不做任何操作:
class SSLCustomPaintRouteTest extends StatelessWidget{
const SSLCustomPaintRouteTest({Key? key}):super(key: key);
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
appBar: AppBar(
title: const Text("SSL Custom Paint"),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
size: const Size(300, 300),
painter: SSLPainter(),
),
ElevatedButton(onPressed: (){
}, child: const Text("Refresh")
),
],
)
),
);
}
}
发现控制台输出了很多“ssl painter paint”,也就是点击按钮时发生了多次重绘。但是在自定义CustomPainter时shouldRepaint返回的是false,点击刷新按钮按道理讲应该是不触发重绘的。这个后面再研究,目前简单认为按钮和CustomPaint是同一个画布,点击按钮会执行水波纹动画,水波纹动画执行过程中画布会不停的刷新,导致了CustomPaint不停的重绘。解决方案是给CustomPainter或者按钮任意一个添加RepaintBoundary父组件即可,考虑到实际场景中,可能会有多个组件,所以建议优先考虑给CustomPainter加,这样可以把CustomPaint隔离开来:
class SSLCustomPaintRouteTestState extends State<SSLCustomPaintRouteTest>{
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
appBar: AppBar(
title: const Text("SSL Custom Paint"),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
CustomPaint(
size: const Size(300, 300),
painter: SSLPainter(),
),
const RepaintBoundary(
child: SSLCustomChessRoute(),
),
],
),
ElevatedButton(onPressed: (){
}, child: const Text("Refresh")
),
],
)
),
);
}
}
优化代码逻辑后,可实现正常的落子,且只会绘制落子部分。
转载自:https://juejin.cn/post/7223043596621971512