目标:绘制小猪佩奇
Flutter在自定义绘制上同Android类似,提供给你canvas和paint,剩下的就海阔凭鱼跃,天高任鸟飞了,给了你工具,能造出什么来就凭个人本事了。
这里为了学习与巩固flutter中的绘制相关API,于是使用其参照上图绘制一张小猪佩奇。
这里为了更有扩展性,让画布变大时绘制的内容不会变形,全图使用宽高比例绘制。
这张图相对比较简单,曲线部分全部使用贝塞尔曲线绘制,由于canvas 的API中最高支持3阶贝塞尔曲线,本图绘制正好也不需要使用更高阶的贝塞尔曲线。
先上一张最终效果器:
首先我们要自定义一个绘制佩奇的控件PeppaPaint,继承自CustomPainter,需要我们实现paint(Canvas canvas, Size size)
和bool shouldRepaint(CustomPainter oldDelegate)
的方法:
class PeppaPaint extends CustomPainter{
}
Paint方法中即为我们真正绘制内容的区域,shouldRepaint则控制是否需要重新绘制界面,由于flutter中所有视图内容都是和数据状态绑定的,所有我们可以判断数据状态是否有改变来控制是否重绘,以减少性能损耗。
首先我们做一下准备工作,从size拿到画布的宽高,由于绘制过程中难免会有画笔粗细,圆的半径大小等相对绝对的数值,所有我们这边记一个标准,相当于我们在这个标准的尺寸下用多粗的画笔,多长的半径,在画板尺寸放大或者缩小时,对应的画笔粗细,半径尺寸也会有对应比例的变化。另外需要注意一下,下面绘制的所有基于宽高绘制的定位点都是本子自己大致估算而来大家不必纠结。这里我们以宽度为360作为标准尺寸,则有以下代码:
@override
void paint(Canvas canvas, Size size) {
var width = size.width;
var height = size.height;
var k = width / 360; //对应标准尺寸下的比例尺
……
}
接下来开始真正会内容绘制,首先我们需要绘制一个背景:
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0x77cdb175); //背景为纸黄色
canvas.drawRect(Offset.zero & size, paint);
接下来我们开始画鼻子,鼻子为一个圆孔和一个椭圆:
paint
..style = PaintingStyle.stroke //线
..color = Colors.pink[200]
..strokeWidth = 3.0 * k;
canvas.rotate(-0.25);
//画鼻子
canvas.drawOval(
Rect.fromLTRB(width * 0.7, height * 0.2, width * 0.8, height * 0.30),
paint);
paint.style = PaintingStyle.fill; //填充
canvas.drawCircle(Offset(width * 0.725, height * 0.25), 5 * k, paint);
canvas.drawCircle(Offset(width * 0.775, height * 0.25), 5 * k, paint);
大家可能注意到上面有一句canvas.rotate(-0.25)
,这是因为鼻子的椭圆是有一定角度的,我们需要将画布旋转一定角度后作画。由于脸部轮廓和鼻子是连着的,所以我们不着急把画布旋转回来,否则旋转回来之后就找不到鼻子与脸部轮廓链接的点了。我们接着画脸部,脸部是由两个贝塞尔曲线构成,一条三阶贝塞尔连接到右下嘴角部分,一条二阶贝塞尔连接嘴角与鼻子:
//画脸部轮廓
var path = new Path();
paint.style = PaintingStyle.stroke; //
path.moveTo(width * 0.75, height * 0.2);
path.cubicTo(-width * 0.2, height * 0.05, width * 0.25, height * 0.63,
width * 0.6, height * 0.35);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.6, height * 0.35);
path.conicTo(width * 0.7, height * 0.33, width * 0.75, height * 0.3, 1);
canvas.drawPath(path, paint);
canvas.rotate(0.25);
画完脸部由于没有需要直接和鼻子与脸部的定位点连接的部位了(耳朵和身体不是和脸部的定位点连接的,脸部确是和鼻子的定位点连接的),所有可以将画布拜正了,后续都以这个角度绘制。
下面绘制耳朵,耳朵也是两条三阶贝塞尔曲线:
//画耳朵
path = new Path();
path.moveTo(width * 0.5, height * 0.1);
path.cubicTo(width * 0.42, height * 0.01, width * 0.58, height * 0.01,
width * 0.55, height * 0.09);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.41, height * 0.12);
path.cubicTo(width * 0.31, height * 0.035, width * 0.46, height * 0.035,
width * 0.46, height * 0.105);
canvas.drawPath(path, paint);
下面绘制眼睛,嘴,和标志性的小圆脸蛋:
//画小酒窝
paint.style = PaintingStyle.fill; //填充
canvas.drawCircle(Offset(width * 0.40, height * 0.23), 18 * k, paint);
//画眼睛
paint.style = PaintingStyle.stroke; //线
canvas.drawCircle(Offset(width * 0.60, height * 0.13), 9 * k, paint);
canvas.drawCircle(Offset(width * 0.50, height * 0.15), 9 * k, paint);
//眼珠
paint.style = PaintingStyle.fill; //线
paint.color = Colors.black;
canvas.drawCircle(Offset(width * 0.59, height * 0.133), 4 * k, paint);
canvas.drawCircle(Offset(width * 0.49, height * 0.153), 4 * k, paint);
//画嘴
path = new Path();
paint.style = PaintingStyle.stroke; //线
paint.color = Colors.pink;
path.moveTo(width * 0.45, height * 0.28);
path.conicTo(width * 0.55, height * 0.33, width * 0.62, height * 0.25, 1);
canvas.drawPath(path, paint);
整个头部就算绘制完成了,接下来就是身体部分的绘制,身体部分要注意的就是底部曲线连接部分,我这边左右两边都使用了一个圆弧来连接,否则看起来会比较突兀,具体代码如下:
path = new Path();
paint.color = Colors.pink[200];
path.moveTo(width * 0.37, height * 0.327);
path.conicTo(width * 0.25, height * 0.42, width * 0.25, height * 0.6, 1);
canvas.drawPath(path, paint);
path = new Path();
paint.color = Colors.pink[200];
path.moveTo(width * 0.62, height * 0.325);
path.conicTo(width * 0.72, height * 0.42, width * 0.75, height * 0.6, 1);
canvas.drawPath(path, paint);
canvas.drawArc(
Rect.fromCircle(
center: Offset(width * 0.284, height * 0.6), radius: 12 * k),
1.57,
1.58,
false,
paint);
canvas.drawArc(
Rect.fromCircle(
center: Offset(width * 0.7165, height * 0.6), radius: 12 * k),
0,
1.58,
false,
paint);
canvas.drawLine(Offset(width * 0.284, height * 0.6 + (12 * k)),
Offset(width * 0.7165, height * 0.6 + (12 * k)), paint);
接下来绘制手部,左右手大同小异,主要是点位点估算与调试比较费事:
//画右手
path = new Path();
path.moveTo(width * 0.68, height * 0.4);
path.conicTo(width * 0.75, height * 0.44, width * 0.80, height * 0.48, 1);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.78, height * 0.466);
path.conicTo(width * 0.80, height * 0.463, width * 0.81, height * 0.46, 1);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.78, height * 0.466);
path.conicTo(width * 0.78, height * 0.47, width * 0.769, height * 0.486, 1);
canvas.drawPath(path, paint);
paint.style = PaintingStyle.fill;
canvas.drawCircle(Offset(width * 0.80, height * 0.48), 1.5 * k, paint);
canvas.drawCircle(Offset(width * 0.81, height * 0.46), 1.5 * k, paint);
canvas.drawCircle(Offset(width * 0.769, height * 0.486), 1.5 * k, paint);
接下来就是绘制小尾巴,小尾巴这里挺有意思的,由于实在没办法用一条曲线画出来,就只能两条曲线来凑了:
//画尾巴
paint.style = PaintingStyle.stroke;
path = new Path();
path.moveTo(width * 0.225, height * 0.568);
path.cubicTo(width * 0.18, height * 0.56, width * 0.21, height * 0.60,
width * 0.25, height * 0.585);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.225, height * 0.568);
path.cubicTo(width * 0.24, height * 0.57, width * 0.21, height * 0.60,
width * 0.18, height * 0.585);
canvas.drawPath(path, paint);
脚的地方比较简单,两条曲线加两个椭圆:
//画脚
path = new Path();
path.moveTo(width * 0.42, height * 0.6 + (12 * k));
path.conicTo(width * 0.41, height * 0.65, width * 0.42, height * 0.69, 1);
canvas.drawPath(path, paint);
path = new Path();
path.moveTo(width * 0.58, height * 0.6 + (12 * k));
path.conicTo(width * 0.57, height * 0.65, width * 0.58, height * 0.69, 1);
canvas.drawPath(path, paint);
paint.color = Colors.teal[800];
paint.style = PaintingStyle.fill;
canvas.drawOval(
Rect.fromLTRB(
width * 0.41, height * 0.685, width * 0.45, height * 0.70),
paint);
canvas.drawOval(
Rect.fromLTRB(
width * 0.57, height * 0.685, width * 0.61, height * 0.70),
paint);
至此,咱们的佩奇已经画完了,和参考的图片对比一下,发现还是有几分相似的,哈哈。
最后我们要给佩奇画上签名文字了,这里我找了一下,发现flutter绘制文字还是比较麻烦的,不像Android里面直接drawtext()就搞定了。这里也没有导入字体,使用的默认的字体,具体代码如下:
//画文字
ParagraphBuilder pb = ParagraphBuilder(ParagraphStyle(
textAlign: TextAlign.center,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
fontSize: 35.0 * k,
));
pb.pushStyle(ui.TextStyle(color: Colors.green));
pb.addText('peppapig');
ParagraphConstraints pc = ParagraphConstraints(width: width);
//这里需要先layout, 后面才能获取到文字高度
Paragraph paragraph = pb.build()..layout(pc);
//文字居中显示
Offset offset = Offset(
width / 2 - paragraph.width / 2, height * 0.71 + paragraph.height / 2);
canvas.drawParagraph(paragraph, offset);
大功告成!接下来我们要检验我们绘制的图的兼容性了,在放大和缩小的情况下,看看咱们的图是否会变形,各个定位点是否会错位。在放大和缩小的时候我们需要注意一下,需要按照宽高比等比例的缩放,否则一定会导致画的图变形。所以还需要自己设置一个标准的宽高比,按照对应的宽高比设置缩放后的尺寸:
var width = 50.0;
var height;
var lastScale = 1.0;
@override
void initState() {
super.initState();
width = 50.0;
height = getHeight(width);
}
double getHeight(double width) {
return 558.0 * width / 360;
}
在展示部分,我们引入一个手势缩放控件,来缩放咱们画的图,看看是否会变形:
Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: GestureDetector(
onDoubleTap: () {
setState(() {
width = 1.2 * width;
height = getHeight(width);
});
},
onScaleStart: (s) {
lastScale = 1.0;
},
onScaleUpdate: (scale) {
setState(() {
width = (scale.scale / lastScale) * width;
height = getHeight(width);
lastScale = scale.scale;
});
},
child: CustomPaint(
size: Size(width, height),
painter: PeppaPaint(),
),
),
),
);
需要注意的是,在绘制控件中,paint()
方法中的size
为实际的画布尺寸,与CustomPaint
设置的尺寸不一定相同,因为可能屏幕没有那么长或者那么宽,所以为了让绘制的宽高比保持正常,需要在paint方法中,重新设置宽高,当宽高比与标准宽高比大时,表示图片过宽,应该以高为基准,按照标准宽高比重新计算宽度,然后按照重新计算的宽高进行绘制。反之亦然。这里为了方便,统一以宽度为基准,重新计算高度,得到的绘制结果不会变形,但也可能会超出屏幕。
void paint(Canvas canvas, Size size) {
var width = size.width;
var height = getHeight(size.width);
……
}
本来本文到此就结束了,但是……总觉得画的文字的这个字体看着不太协调,顺便识别了一下原图的字体。
死活下不来这个字体,某某网站该字体需要VIP才能下载…….
只能上Google字体库找找类似的字体了。
https://fonts.google.com/
下载字体后需在pubspec.yaml配置文件中配置我们的字体:
fonts:
- family: Berkshire Swash
fonts:
#https://fonts.googleapis.com/css?family=Berkshire+Swash
- asset: fonts/BerkshireSwash-Regular.ttf
需要把字体文件放到配置的对应文件夹下。
对应的使用就非常简单了,只需要指明需要使用的字体库:、
加载该字体后效果图:
最后奉上源码地址:https://github.com/1126174422/flutter-peppapig