Flutter内置了很多的组件,我们可以使用一个有状态或者无状态组件来将这些内置的基础组件进行组合,从而实现我们的布局。但是在实际项目中,这些内置的组件肯定是无法完全满足我们的要求的,因此需要我们自定义组件,一点点绘制出我们想要的结果。
CustomPaint
在Android中自定义View通常是继承自一些内置的基础组件或者最顶层的View\ViewGroup,然后根据需求实现它的测量布局绘制三个方法。在Flutter中稍微有一点不同,就是我们并不是直接继承各种Widget,而是通过一个布局容器CustomPaint,我们直接在这个容器内进行绘制。
1 2 3 4 5 6 7 8 9
| const CustomPaint({ super.key, this.painter, this.foregroundPainter, this.size = Size.zero, this.isComplex = false, this.willChange = false, super.child, })
|
这个Widget本身并没有内容,而是提供了painter和foregroundPainter来供我们自定义绘制。我们一般会在painter中进行绘制,如果该组件有其他子组件,即child不为空的时候,我们还可以在foregroundPainter中绘制前景。
正常就是先在图层上显示painter绘制的内容,然后在这些内容上覆盖child中的组件内容,最后绘制foregroundPainter内容。
其他属性也比较简单,size是该组件的大小,isComplex表示是否是复杂组件从而决定是否需要加入到缓存中从而避免多次绘制,如果不设置的话则由内置的组合器中的逻辑来决定是否缓存,willChange表示下一帧是否会变化,如果设置了则不会进行缓存。
可以看到还是和Android中的自定义区别挺大的,在Flutter中,直接通过CustomPaint组件进行自定义,我们不需要关注组件是如何实现的,如何布局的,只需要实现抽象出来的两个painter即可。
CustomPainter
注意,CustomPaint是一个组件Widget,而CustomPainter则是一个绘制器,用于在CustomPaint中进行布局绘制的。
1 2 3 4
| abstract class CustomPainter extends Listenable { void paint(Canvas canvas, Size size); bool shouldRepaint(covariant CustomPainter oldDelegate); }
|
本身是一个抽象类,一共需要我们实现两个抽象方法,paint决定如何绘制,shouldRepaint表示是否需要重绘。
在paint方法中,参数canvas表示画布,和Android中的画布一样,这里也是通过canvas进行各种绘制工作。例如下面,我们绘制五行的表格:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class MyPainter extends CustomPainter { final Paint _paint = Paint() ..color = Colors.black87 ..isAntiAlias = true;
@override void paint(Canvas canvas, Size size) { final perWidth = size.width / 5; for (double i = 0; i <= size.width; i += perWidth) { canvas.drawLine(Offset(i, 0), Offset(i, size.height), _paint); } final perHeight = size.height / 5; for (double i = 0; i <= size.height; i += perHeight) { canvas.drawLine(Offset(0, i), Offset(size.width, i), _paint); } }
@override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }
|
直接在需要该组件的地方声明即可:
1
| CustomPaint(size: Size(300, 200), painter: MyPainter()),
|
也就是说,对于普通的自定义组件,我们直接使用CustomPaint即可实现我们的需求,当然,这仅限于我们的组件只有自身,而没有子组件。虽然CustomPaint给我们提供了一个child属性,但实际使用中却是与我们预想的不符。例如前面的例子中,我们绘制了一个5*5的表格,然后有一个需求需要在表格的最中间添加一个文本,按照正常思路:
1 2 3 4 5
| CustomPaint( size: Size(300, 200), painter: MyPainter(), child: Center(child: Text('Hello')), ),
|
但实际上效果却不是如我们想的那样,此时size失效了,表格的宽度占了整个屏幕,高度为Text的高度。如果想要实现我们想要的结果,就不能直接将Text放在CustomPaint的child属性中,而是需要将其提取到外部,使用Stack包裹。
1 2 3 4 5 6 7 8 9 10
| Stack( alignment: Alignment.center, children: [ CustomPaint( size: Size(300, 200), painter: MyPainter(), ), Center(child: Text('Hello')), ], )
|
Paint
绘制方法和安卓中是一致的,需要一个画布Canvas和一个画笔Paint,来实现绘制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| final Paint _paint = Paint() ..style = PaintingStyle.stroke ..color = Colors.black87 ..isAntiAlias = true ..strokeWidth = 5 ..strokeCap = StrokeCap.square ..strokeJoin = StrokeJoin.bevel ..strokeMiterLimit = 5 ..invertColors = true;
|
以上是画笔的一些设置,主要就是设置画笔粗细、颜色、圆角等属性。
blendMode
同时也有一些比较复杂的属性可以设置,如blendMode设置的混入模式,实际上和安卓也是一样的。注意混入模式必须要在图层上进行混入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class MyPainter extends CustomPainter { final int _index; MyPainter(this._index);
void paint(Canvas canvas, Size size) { Paint paint = Paint()..isAntiAlias = true; double quarter = size.width / 4; canvas.saveLayer(Offset.zero & size, paint); canvas.drawRect(Rect.fromLTRB(0, 0, quarter * 3, quarter * 3), paint..color = Colors.blue); canvas.saveLayer( Offset.zero & size, Paint()..blendMode = BlendMode.values[_index], ); canvas.drawRect(Rect.fromLTRB(quarter, quarter, size.width, size.height), paint..color = Colors.red); canvas.restoreToCount(2); }
@override bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate != this; }
|
上述代码中,通过saveLayer创建了两个图层,第一个图层中在左上角绘制了四分之三大小的矩形,第二个图层在右下角绘制了四分之三的图层。当调用restore恢复图层时,就会应用saveLayer时传入的那个paint的混入模式,此时当前图层作为src,前一个图层作为dst。
这里我们将所有的混入模式都输出一下看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 10, ), itemBuilder: (_, index) { return Container( decoration: BoxDecoration(border: Border.all(width: 1)), child: CustomPaint( painter: MyPainter(index), child: Text(BlendMode.values[index].name), ), ); }, itemCount: BlendMode.values.length, padding: EdgeInsets.all(15), ), ), ); }
|
该代码运行后在手机上跑起来是如下图所示的,可以看到每种的混入模式的效果:

clear:清除src和dst的内容,表现下来就是什么都没有。src:只显示src图层上的内容dst:只显示dst图层上的内容srcOver:都显示,但是src图层显示在上面dstOver:都显示,但是dst图层显示在上面srcIn:只显示两个图层相交的部分,并且显示的是相交部分的src内容dstIn:只显示两个图层相交的部分,并且显示的是相交部分的dst内容srcOut:显示src图层的内容,但是需要抠出相交的部分dstOut:显示dst图层的内容,但是需要抠出相交的部分srcATop:显示dst图层的内容,但是相交部分显示src图层的内容dstATop:显示src图层的内容,但是相交部分显示dst图层的内容xor:两个图层都显示,但是需要抠出相交部分plus:两个图层都显示,相交部分的颜色进行相加,做图像的渐变时可以使用modulate:只显示两个图层相交的部分,相交部分的颜色分量进行相乘,只会产生更深的颜色screen:两个图层都显示,相交部分的颜色分量反转后进行相乘,再将结果反转,只会产生更浅的颜色overlay:两个图层都显示,相交部分的颜色分量进行相乘,如果dst的值较小,则直接相乘;否则反转两个分量的值后进行相乘,再将结果反转。darken:两个图层都显示,相交部分从每个颜色的通道中选最小的值进行合成lighten:两个图层都显示,相交部分从每个颜色的通道中选最大的值进行合成colorDodge:两个图层都显示,相交部分用dst的颜色除以反转后的srccolorBurn:两个图层都显示,相交部分用反转后的dst除以src,然后再将结果反转hardLight:两个图层都显示,相交部分的颜色分量进行相乘,如果dst的值较小,则反转两个值后再进行相乘,然后再将结果反转;否则直接相乘(和overlay相反)softLight:两个图层都显示,相交部分中对低于0.5的值使用colorDodge模式,高于0.5的值使用colorBurn模式difference:两个图层都显示,相交部分用每个颜色通道值中较大的减去较小的exclusion:两个图层都显示,相交部分用两个颜色的和减去两个颜色的积的二倍multiply:两个图层都显示,相交部分的颜色分量进行相乘,也包含了alpha通道,只会产生更深的颜色(比modulate多一个alpha通道)hue:两个图层都显示,相交部分显示src的色调,但是显示dst的饱和度和亮度,能看出来覆盖的阴影效果saturation:两个图层都显示,相交部分显示dst的色调,但是显示src的饱和度和亮度,能看出来覆盖的阴影效果color:两个图层都显示,相交部分显示src的色调和饱和度,但是显示dst的亮度luminosity:两个图层都显示,相交部分显示dst的色度和饱和度,但是显示src的亮度
shader
图像着色器,用于进行着色,主要是绘制一些封闭图形时填充色,可以是一个图像也可以是一个颜色。如果设置了shader,则color属性会失效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| Gradient.linear( Offset from, Offset to, List<Color> colors, [ List<double>? colorStops, TileMode tileMode = TileMode.clamp, Float64List? matrix4, ])
Gradient.radial( Offset center, double radius, List<Color> colors, [ List<double>? colorStops, TileMode tileMode = TileMode.clamp, Float64List? matrix4, Offset? focal, double focalRadius = 0.0, ]) Gradient.sweep( Offset center, List<Color> colors, [ List<double>? colorStops, TileMode tileMode = TileMode.clamp, double startAngle = 0.0, double endAngle = math.pi * 2, Float64List? matrix4, ])
|
对于渐变着色器,有三种可以选择,分别是水平渐变、雷达渐变、扫描渐变,这些都是一致的,注意在使用的时候它的名字和material包中的渐变的名字是一样的,因此使用时需要加上别名。
1 2 3 4 5 6 7 8 9
| import 'dart:ui' as ui;
Paint paint = Paint() ..isAntiAlias = true ..shader = ui.Gradient.linear(Offset(0, 0), Offset(50, 50), [ Colors.blue, Colors.green, ]);
|
基本上Paint就以上这些属性,通过设置对应的属性来实现不同的效果,当然,如果要想进行绘制还需要有Canvas画布的配合。
Canvas
Canvas作为画布是与Paint互相配合的,画笔设置的是各种绘制颜色线条粗细等等,而画布则是实际进行绘制的,它提供了大量的方法,基本上都是draw开头的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| void drawColor(Color color, BlendMode blendMode);
void drawPaint(Paint paint);
void drawLine(Offset p1, Offset p2, Paint paint);
void drawRect(Rect rect, Paint paint);
void drawRRect(RRect rrect, Paint paint);
void drawDRRect(RRect outer, RRect inner, Paint paint);
void drawRSuperellipse(RSuperellipse rsuperellipse, Paint paint);
void drawOval(Rect rect, Paint paint);
void drawCircle(Offset c, double radius, Paint paint);
void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint);
void drawPath(Path path, Paint paint);
void drawImage(Image image, Offset offset, Paint paint); void drawImageRect(Image image, Rect src, Rect dst, Paint paint); void drawPicture(Picture picture);
void drawParagraph(Paragraph paragraph, Offset offset);
|
大体上就是上述的一些绘制内容,有一点不同的就是绘制文字的方法,并没有传入Paint方法,而是直接传入的Paragraph对象即可。
1
| ui.ParagraphBuilder builder = ui.ParagraphBuilder(ui.ParagraphStyle());
|
首先需要通过ParagraphBuilder构建文字段落,构造方法需要传入ParagraphStyle样式,主要控制文字的字体、大小、对其方式等等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ParagraphStyle({ TextAlign? textAlign, TextDirection? textDirection, int? maxLines, String? fontFamily, double? fontSize, double? height, TextHeightBehavior? textHeightBehavior, FontWeight? fontWeight, FontStyle? fontStyle, StrutStyle? strutStyle, String? ellipsis, Locale? locale, })
|
当创建了ParagraphBuilder后,就可以往里面添加文案了,通过addText进行添加。
1 2 3 4 5 6
| void pushStyle(TextStyle style);
void pop();
void addText(String text);
|
并且在输出之前,必须要先进行layout,完整的绘制文本的逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ui.ParagraphBuilder builder = ui.ParagraphBuilder(ui.ParagraphStyle( textAlign: TextAlign.left, fontSize: 30 ));
builder.pushStyle(ui.TextStyle(color: Colors.red));
builder.addText('hello');
ui.Paragraph paragraph = builder.build();
paragraph.layout(ui.ParagraphConstraints(width: size.width));
canvas.drawParagraph(paragraph, Offset.zero);
|
总结
基本上,绘制方面就是这些。其实就是直接使用的CustomPaint,然后在需要提供的CustomPainter中,使用画笔和画布进行绘制。实际上我们并没有实现自定义组件,而只是实现了自定绘制部分,我们无法控制整个组件的布局操作和测量操作,只能完成绘制部分。