0%

Flutter使用Canvas实现小白兔的绘制

flutter_radish_cover

前言

前面两篇文章讲解了在 Flutter 中使用 Canvas 分别实现了 精美表盘微信红包 效果,本篇将继续带领你使用 Canvas 实现简笔的小白兔效果,使用的核心技术为二次贝塞尔曲线和三次贝塞尔曲线的运用。

按照惯例,先看一下最终实现的效果:

radish

实现

仔细观察上面的效果图,可以发现简笔的小白兔实际上是通过多个不同形状、不同位置的 ”3“ 的图形组成的,所以核心就是如何绘制 ”3“ 的形状,这里采用两个三次贝塞尔曲线来绘制。

整体的绘制是在 CustomPainterpaint 方法中进行,所以首先创建一个 RabbitPainter 继承自 CustomPainter ,然后在 Widget 中通过 CustomPaint 进行使用,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class RabbitPainter extends CustomPainter{

@override
void paint(Canvas canvas, Size size) async{

}
}

Container(
color: Colors.white,
child: Center(
child: CustomPaint(
painter: RabbitPainter(),
size: Size(0.8.sw, 1.sw),
),
),
);

绘制 ”3“

前面说到了使用三次贝塞尔曲线绘制 ”3“ 的形状,而三次贝塞尔曲线需要 4 个点,两个端点和两个曲线控制曲线的点,如下图所示:

rabit_bezier_curve

使用代码中使用 Path 创建三次贝塞尔曲线路径的代码如下:

1
2
3
Path path = Path();
path.moveTo(x, y);
path.cubicTo(x1, y1, x2, y2, x3, y3);

这就创建了半个 ”3“ ,在此基础上再添加一个三次贝塞尔曲线就实现了一个 ”3“ 的图形路径,封装方法如下:

1
2
3
4
5
6
7
8
/// 创建3形状的path
Path createThreePath(List<Offset> points) {
Path path = Path();
path.moveToPoint(points[0]);
path.cubicToPoints(points.sublist(1, 4));
path.cubicToPoints(points.sublist(4, 7));
return path;
}

传入的参数是一个点的集合,共七个点,创建一个 Path 首先 moveTo 到第一个点,然后将其余 6 个点分两次每次 3 个点添加两个三次贝塞尔曲线到 Path 中,最终组成了一个 ”3“ 的图形。

其中 moveToPointcubicToPoints 是自定义扩展 Path 的方法,方便使用,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
extension PathExt on Path{

void moveToPoint(Offset point){
moveTo(point.dx, point.dy);
}

void cubicToPoints(List<Offset> points){
if(points.length != 3){
throw "params points length must 3";
}
cubicTo(points[0].dx, points[0].dy, points[1].dx, points[1].dy, points[2].dx, points[2].dy);
}
}

至此就实现了对一个 ”3“ 的形状的封装。

身体

首先绘制小白兔的主体,也就是左右两边的身体轮廓,而左右两边的身体轮廓则是由一个反向的 ”3“ 和一个正向的 ”3“ 组成的,所以首先我们使用上面封装好的方法绘制一个反向 ”3“ 的形状。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
List<Offset> createLeftBodyPoints(){
var position1 = Offset(110.w, 100.w);
var position2 = Offset(30.w, position1.dy + 20.w);
var position3 = Offset(40.w, position2.dy + 100.w);
var position4 = Offset(110.w, position3.dy + 10.w);

var position5 = Offset(50.w, position4.dy + 20.w);
var position6 = Offset(60.w, position5.dy + 80.w);
var position7 = Offset(125.w, position6.dy + 10.w);

return [
position1,
position2,
position3,
position4,
position5,
position6,
position7
];
}

var leftBodyPoints = createLeftBodyPoints();
var leftBodyPath = createThreePath(leftBodyPoints);
canvas.drawPath(leftBodyPath, _paint);

首先创建 7 个点,也就是用于绘制 ”3“ 形状的 7 个点,然后调用封装好的方法创建一个 Path,再使用 Canvas.drawPath 将图形绘制出来。

这里使用数值如 110.w 为适配单位,关于 Flutter 中的屏幕适配请参考 : Flutter应用框架搭建(二)屏幕适配

实现效果如下:

image-20220324221240036

这样就绘制出了兔子左边身体轮廓了,使用同样的方法是不是就可以绘制出右边的轮廓了呢,当然可以,但是这里又更简单的办法,那就是将左边的 Path 进行一个翻转就行了,如下:

1
2
3
4
var matrix4 = Matrix4.translationValues(0.8.sw, 0, 0);
matrix4.rotateY(2*pi/2);
var rightBodyPath = leftBodyPath.transform(matrix4.storage);
canvas.drawPath(rightBodyPath, _paint);

使用 Matrix4 对 Path 进行平移和旋转,这里为什么平移 0.8.sw 是因为画布的宽度设置的 0.8.sw,即将 x 平移到画布最右边,然后对 Y 轴旋转 180 度,即将图形翻转过来,最终实现效果如下:

image-20220324221341315

这样身体的左右轮廓就实现了。

耳朵

耳朵实际上也是一个 ”3“ 的形状,只是是倒着放的,并且往上拉伸将 ”3“ 的两个凸形显得更凸出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var leftFirstPosition = leftBodyPath.getPositionFromPercent(0);
var rightFirstPosition = rightBodyPath.getPositionFromPercent(0);

var centerWidth = rightFirstPosition.dx - leftFirstPosition.dx;

var position1 = Offset(leftFirstPosition.dx, leftFirstPosition.dy);
var position2 = Offset(leftFirstPosition.dx -50.w, -20.w);
var position3 = Offset(leftFirstPosition.dx + centerWidth/2, -20.w);
var position4 = Offset(leftFirstPosition.dx + centerWidth/2, leftFirstPosition.dy);
var position5 = Offset(leftFirstPosition.dx + centerWidth/2 + 5.w, -12.w);
var position6 = Offset(rightFirstPosition.dx + 55.w, -12.w);
var position7 = Offset(rightFirstPosition.dx, rightFirstPosition.dy);

var points = [position1, position2, position3, position4, position5, position6, position7];

var earPath = createThreePath(points);

canvas.drawPath(earPath, _paint);

耳朵的两个端点分别为左边身体的起点和右边身体的起点,所以先计算出左右两边图形的第一个点,并计算出两个点的间距。其中 getPositionFromPercent 也是自定义扩展 Path 的方法,用于通过百分比得到在 Path 路径上的对应的点,实现如下:

1
2
3
4
5
6
7
8
extension PathExt on Path{
Offset getPositionFromPercent(double percent){
var pms = computeMetrics();
var pm = pms.first;
var position = pm.getTangentForOffset(pm.length * percent)?.position ?? Offset.zero;
return position;
}
}

然后创建用于构建 ”3“ 图形的七个点,最终实现耳朵的效果:

image-20220324221411569

手脚

兔子的手脚也是由两个 “3” 的图形组成的,一边的一只手一只脚为一个 “3”,先绘制左边的手脚,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var handsFeetPosition1 = Offset(leftBodyPoints[3].dx + 10.w, leftBodyPoints[3].dy + 10.w);
var handsFeetPosition2 = Offset(handsFeetPosition1.dx + 20.w, handsFeetPosition1.dy + 5.w);
var handsFeetPosition3 = Offset(handsFeetPosition2.dx + 20.w, handsFeetPosition2.dy + 40.w);
var handsFeetPosition4 = Offset(handsFeetPosition1.dx, handsFeetPosition3.dy + 15.w);

var handsFeetPosition5 = Offset(handsFeetPosition4.dx + 20.w, handsFeetPosition4.dy + 10.w);
var handsFeetPosition6 = Offset(handsFeetPosition5.dx + 10.w, handsFeetPosition5.dy + 20.w);
var handsFeetPosition7 = Offset(leftBodyPoints.last.dx, leftBodyPoints.last.dy);
var leftHandsFeetPoints = [
handsFeetPosition1,
handsFeetPosition2,
handsFeetPosition3,
handsFeetPosition4,
handsFeetPosition5,
handsFeetPosition6,
handsFeetPosition7,
];

var leftHandsFeetPath = createThreePath(leftHandsFeetPoints);
canvas.drawPath(leftHandsFeetPath, _paint);

同样是创建绘制 “3” 的 7 个点,这里起始点是以兔子身体左边的中心端点作为起始点进行偏移的,绘制效果如下:

image-20220324221615660

用同样的方法绘制右边的手脚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var rightHandsFeetPosition1 = Offset(leftBodyPoints[3].dx + 80.w, leftBodyPoints[3].dy + 15.w);
var rightHandsFeetPosition2 = Offset(rightHandsFeetPosition1.dx - 20.w, rightHandsFeetPosition1.dy + 5.w);
var rightHandsFeetPosition3 = Offset(rightHandsFeetPosition2.dx - 15.w, rightHandsFeetPosition2.dy + 30.w);
var rightHandsFeetPosition4 = Offset(rightHandsFeetPosition1.dx - 15.w, rightHandsFeetPosition3.dy + 15.w);

var rightHandsFeetPosition5 = Offset(rightHandsFeetPosition4.dx - 15.w, rightHandsFeetPosition4.dy + 10.w);
var rightHandsFeetPosition6 = Offset(rightHandsFeetPosition5.dx - 5.w, rightHandsFeetPosition5.dy + 20.w);

var rightLastPosition = rightPath.getPositionFromPercent(1);
var rightHandsFeetPosition7 = Offset(rightLastPosition.dx, rightLastPosition.dy);
var rightHandsFeetPoints = [
rightHandsFeetPosition1,
rightHandsFeetPosition2,
rightHandsFeetPosition3,
rightHandsFeetPosition4,
rightHandsFeetPosition5,
rightHandsFeetPosition6,
rightHandsFeetPosition7,
];
var rightHandsFeetPath = createThreePath(rightHandsFeetPoints);

canvas.drawPath(rightHandsFeetPath, _paint);

效果如下:

image-20220324221647707

胡萝卜叶

萝卜叶也是由一个 “3” 组成的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
var point1 = Offset(leftHandsFeetPoints.first.dx + 20.w, leftHandsFeetPoints.first.dy - 5.w);
var point2 = Offset(leftHandsFeetPoints.first.dx -5.w, leftHandsFeetPoints.first.dy -45.w);
var point3 = Offset(leftHandsFeetPoints.first.dx + 45.w, leftHandsFeetPoints.first.dy-45.w);
var point4 = Offset(leftHandsFeetPoints.first.dx + 35.w, leftHandsFeetPoints.first.dy - 10.w);
var point5 = Offset(leftHandsFeetPoints.first.dx + 40.w, leftHandsFeetPoints.first.dy -35.w);
var point6 = Offset(rightFirstPosition.dx + 0.w, leftHandsFeetPoints.first.dy-35.w);
var point7 = Offset(leftHandsFeetPoints.first.dx + 50.w, leftHandsFeetPoints.first.dy - 5.w);

var points = [point1, point2, point3, point4, point5, point6, point7];
Path radishLeafPath = createThreePath(points);
canvas.drawPath(radishLeafPath, _paint)

萝卜叶的 “3” 以左右两边的手脚路径的起点作为参考点进行一定单位的便宜,绘制效果如下:

image-20220324221725245

兔子的嘴也是由一个倒放的 “3” 组成的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var radishHeadMinYPosition = radishLeafPath.getMinYPosition();
var mouthPosition1 = Offset(radishHeadMinYPosition.dx - 10.w, radishHeadMinYPosition.dy - 20.w);
var mouthPosition2 = Offset(mouthPosition1.dx - 2.w, mouthPosition1.dy + 10.w);
var mouthPosition3 = Offset(mouthPosition2.dx + 18.w, mouthPosition2.dy + 5.w);
var mouthPosition4 = Offset(mouthPosition3.dx + 2.w, mouthPosition1.dy + 2.w);

var mouthPosition5 = Offset(mouthPosition4.dx , mouthPosition4.dy + 10.w);
var mouthPosition6 = Offset(mouthPosition5.dx + 18.w, mouthPosition5.dy + 2.w);
var mouthPosition7 = Offset(mouthPosition6.dx + 2.w, mouthPosition1.dy);
var mouthPoints = [
mouthPosition1,
mouthPosition2,
mouthPosition3,
mouthPosition4,
mouthPosition5,
mouthPosition6,
mouthPosition7,
];
var mouthPath = createThreePath(mouthPoints);

canvas.drawPath(mouthPath, _paint);

嘴的位置是以萝卜叶进行参考的,在萝卜叶上方一定位置,这里用到了 getMinYPosition 计算萝卜叶 Path 的 Y 的最小点,该方法也是自定义扩展自 Path 的方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension PathExt on Path{
Offset getMinYPosition(){
var pms = computeMetrics();
var pm = pms.first;
var minPosition = pm.getTangentForOffset(0)?.position;
for(int i = 0; i< pm.length; i++){
var position = pm.getTangentForOffset(i.toDouble())?.position;
if(minPosition == null || (position != null && position.dy < minPosition.dy)){
minPosition = position;
}
}
return minPosition ?? Offset.zero;
}
}

通过计算 Path 上所有的点,循环所有点取出 Y 值最小的点,也就是这里萝卜叶的定点,然后进行一定单位的偏移调整,最终绘制出嘴的图形,效果如下:

image-20220324221756315

眼睛

眼镜终于不是 “3” 了,只需要使用二次贝塞尔曲线就行了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var point1 = Offset(leftBodyPoints.first.dx - 5.w, leftBodyPoints.first.dy + 50.w);
var point2 = Offset(point1.dx + 10.w, point1.dy - 13.w);
var point3 = Offset(point1.dx + 20.w, point1.dy);

Path leftEyesPath = Path();
leftEyesPath.moveToPoint(point1);
leftEyesPath.quadraticBezierToPoints([point2, point3]);

canvas.drawPath(leftEyesPath, _paint);

var rightFirstPosition = rightBodyPath.getPositionFromPercent(0);
var point1 = Offset(rightFirstPosition.dx - 15.w, rightFirstPosition.dy + 50.w);
var point2 = Offset(point1.dx + 10.w, point1.dy - 13.w);
var point3 = Offset(point1.dx + 20.w, point1.dy);

Path rightEyesPath = Path();
rightEyesPath.moveToPoint(point1);
rightEyesPath.quadraticBezierToPoints([point2, point3]);

canvas.drawPath(rightEyesPath, _paint);

眼睛的绘制使用的是二次贝塞尔曲线,只需三个点,即两个端点和一个 控制曲线幅度的点。这里绘制眼睛分别以身体左边起始点和右边起始点作为参考点,因为身体右边轮廓的 Path 是通过左边的旋转获取,所以这里使用 getPositionFromPercent 获取右边 Path 的第一个点,然后进行一定单位的偏移,最终得到如下效果:

image-20220324221913749

胡萝卜

前面绘制了胡萝卜叶,接下来就是绘制胡萝卜的主体,胡萝卜的主体主要是将兔子的左右手脚用曲线相连在中间形成一个封闭图形就是胡萝卜主体了。

先看一下上半部分的曲线绘制:

1
2
3
4
5
6
7
8
9
10
var radishTopPath = Path();
var radishTopPosition1 = leftHandsFeetPath.getPositionFromPercent(0.07);
var radishTopPosition2 = radishLeafPath.getPositionFromPercent(0).translate(0, -6.w);
var radishTopPosition3 = radishLeafPath.getPositionFromPercent(1).translate(0, -9.w);
var radishTopPosition4 = rightHandsFeetPath.getPositionFromPercent(0.07);

radishTopPath.moveToPoint(radishTopPosition1);
radishTopPath.cubicToPoints([radishTopPosition2, radishTopPosition3, radishTopPosition4]);

canvas.drawPath(radishTopPath, _paint);

胡萝卜顶部曲线也是使用三次贝塞尔曲线进行绘制,起始点为左边手脚的路径的 0.07 位置的点,通过 getPositionFromPercent 获取到具体点位坐标,其结束点为右边手脚路径的 0.07 位置,与左边对称。两个曲线控制点已胡萝卜叶的起始点和结束点作为参照进行一定单位的偏移,最终实现效果如下:

image-20220324221943944

接下来看底部曲线的绘制,实现思路与顶部曲线一致,不过底部采用的不是三次贝塞尔曲线,而是二次贝塞尔曲线,以左右手脚路径上指定点(路径上 0.9 位置)作为底部曲线的起始和结束点,曲线控制点以起始点坐标进行一定单位的偏移,代码实现如下:

1
2
3
4
5
6
7
8
9
var radishBottomPath = Path();
var radishBottomPosition1 = leftHandsFeetPath.getPositionFromPercent(0.9);
var radishBottomPosition2 = Offset(radishBottomPosition1.dx + 18.w, radishBottomPosition1.dy+40.w);
var radishBottomPosition3 = rightHandsFeetPath.getPositionFromPercent( 0.9);

radishBottomPath.moveToPoint(radishBottomPosition1);
radishBottomPath.quadraticBezierToPoints([radishBottomPosition2, radishBottomPosition3]);

canvas.drawPath(radishBottomPath, _paint);

效果如下:

image-20220324222020064

上下封闭好后看起来就像一个胡萝卜了,但是总感觉还差点啥,接下来给胡萝卜上加点点缀,让其看起来更像是个胡萝卜。使用二次贝塞尔曲线在胡萝卜内部绘制三条短曲线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var radishBodyPath1 = Path();
var radishBodyPosition1 = leftHandsFeetPath.getPositionFromPercent( 0.3);
var radishBodyPosition2 = Offset(radishBodyPosition1.dx + 5.w, radishBodyPosition1.dy-3.w);
var radishBodyPosition3 = Offset(radishBodyPosition2.dx + 10.w, radishBodyPosition1.dy+3.w);
radishBodyPath1.moveToPoint(radishBodyPosition1);
radishBodyPath1.quadraticBezierToPoints([radishBodyPosition2, radishBodyPosition3]);

var radishBodyPath2 = Path();
var radishBodyPosition4 = rightHandsFeetPath.getPositionFromPercent( 0.7);
var radishBodyPosition5 = Offset(radishBodyPosition4.dx - 5.w, radishBodyPosition4.dy-3.w);
var radishBodyPosition6 = Offset(radishBodyPosition5.dx - 10.w, radishBodyPosition5.dy+3.w);
radishBodyPath2.moveToPoint(radishBodyPosition4);
radishBodyPath2.quadraticBezierToPoints([radishBodyPosition5, radishBodyPosition6]);

var radishBodyPath3 = Path();
var radishBodyPosition7 = rightHandsFeetPath.getPositionFromPercent( 0.78);
var radishBodyPosition8 = Offset(radishBodyPosition7.dx - 3.w, radishBodyPosition7.dy-3.w);
var radishBodyPosition9 = Offset(radishBodyPosition8.dx - 5.w, radishBodyPosition8.dy+3.w);
radishBodyPath3.moveToPoint(radishBodyPosition7);
radishBodyPath3.quadraticBezierToPoints([radishBodyPosition8, radishBodyPosition9]);

canvas.drawPath(radishBodyPath1, _paint);
canvas.drawPath(radishBodyPath2, _paint);
canvas.drawPath(radishBodyPath3, _paint);

分别以左边手脚路径的 0.3 、右边手脚路径的 0.7 和 0.78 作为曲线的起始点,再进行一定单位的偏移,最终绘制出胡萝卜内部的三条曲线进行点缀,最终效果如下:

image-20220324222051303

尾巴

经过上面的绘制后,这个兔子看着就像那么回事了,但还少一个尾巴,毕竟兔子不能没有尾巴不是,尾巴同样是一个三阶贝塞尔曲线,代码如下:

1
2
3
4
5
6
7
8
9
10
var tailPath = Path();
var tailPosition1 = rightBodyPath.getPositionFromPercent(0.8);
var tailPosition2 = Offset(tailPosition1.dx + 35.w, tailPosition1.dy - 30.w);
var tailPosition3 = Offset(tailPosition1.dx + 35.w, tailPosition1.dy + 40.w);
var tailPosition4 = rightBodyPath.getPositionFromPercent(0.9);

tailPath.moveToPoint(tailPosition1);
tailPath.cubicToPoints([tailPosition2, tailPosition3, tailPosition4]);

canvas.drawPath(tailPath, _paint);

以右边身体路径的 0.8 位置为起始点,0.9 位置为结束点,中间以起始点偏移一定单位添加两个控制点,最终实现尾巴的效果,如下图:

image-20220324222122147

整体颜色填充

图形绘制完成后,接下来就是颜色的填充,首先对整个进行白色填充。这里采用的办法是将小白兔最外层的路径合并成一个 Path 然后对这个 Path 使用白色填充,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var bodyBorderPath = Path();
var positionFromPathPercent = earPath.getPositionFromPercent(0);
bodyBorderPath
..moveTo(positionFromPathPercent.dx, positionFromPathPercent.dy)
..addPointsFromPath(earPath)
..addPointsFromPath(rightBodyPath)
..addPointsFromPath(rightHandsFeetPath.getPathFromPercent(0.9, 1), isReverse: true)
..addPointsFromPath(radishBottomPath, isReverse: true)
..addPointsFromPath(leftHandsFeetPath.getPathFromPercent(0.9, 1))
..addPointsFromPath(leftBodyPath, isReverse: true)
..addPath(tailPath, Offset.zero)
..close();

_paint.style = PaintingStyle.fill;
_paint.color = Colors.white;

canvas.drawPath(bodyBorderPath, _paint);

创建 bodyBorderPath 然后获取 earPath (耳朵路径)的第一个点,将 bodyBorderPath 移动到这个点,然后调用 addPointsFromPath 方法将其他 Path 依次添加到 bodyBorderPathaddPointsFromPath 为自定义扩展自 Path 的方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void addPointsFromPath(Path copyPath, {bool isReverse = false}){
var pms = copyPath.computeMetrics();
var pm = pms.first;
if(isReverse){
for(double i = pm.length; i > 0; i--){
var position = pm.getTangentForOffset(i.toDouble())?.position;
if(position != null ){
lineTo(position.dx, position.dy);
}
}
}else{
for(int i = 0; i< pm.length; i++){
var position = pm.getTangentForOffset(i.toDouble())?.position;
if(position != null ){
lineTo(position.dx, position.dy);
}
}
}
}

第一个参数为要添加的 Path,第二个参数 isReverse 表示是否反转即将 Path 的点倒过来添加到当前 Path 中,具体实现为先计算出要添加的 Path 的点,然后循环每一个点使用 lineTo 将每一个点添加到当前 Path。

rightHandsFeetPathleftHandsFeetPath 使用了 getPathFromPercent 方法截取 Path 的一部分添加到 bodyBorderPath 中,而 getPathFromPercent 也是自定义扩展自 Path 方法,代码如下:

1
2
3
4
5
6
Path getPathFromPercent( double startPercent, double endPercent){
var pms = computeMetrics();
var pm = pms.first;
var resultPath = pm.extractPath(pm.length * startPercent, pm.length * endPercent);
return resultPath;
}

代码很简单,计算 Path 的点,然后使用 extractPath 方法获取指定开始位置到指定结束位置的路径。

最终实现效果如下:

image-20220324222202116

耳朵填充

整体填充白色后,接下就为耳朵填充粉色,这里并不是将整个耳朵都填充为粉色,而是填充部分。代码实现如下:

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
_paint.color = Color(0xFFE79EC3);

var leftFirstPosition = leftBodyPath.getPositionFromPercent(0);
var rightFirstPosition = rightBodyPath.getPositionFromPercent(0);
/// 左耳填充
canvas.save();
var leftEarRect = Rect.fromLTWH(leftFirstPosition.dx - 3.w, 25.w, 30.w, (leftFirstPosition.dy - 30.w));
canvas.translate(leftEarRect.center.dx, leftEarRect.center.dy);
Path leftEarPath = Path();
leftEarPath.addOval(Rect.fromLTWH(- leftEarRect.width /2, -leftEarRect.height/2, leftEarRect.width, leftEarRect.height));

leftEarPath = leftEarPath.transform(Matrix4.rotationZ(-pi/15).storage);
canvas.drawPath(leftEarPath, _paint);
canvas.restore();

/// 右耳填充
canvas.save();
var rightEarRect = Rect.fromLTWH(rightFirstPosition.dx - 23.w, 25.w, 30.w, (rightFirstPosition.dy - 30.w));
canvas.translate(rightEarRect.center.dx, rightEarRect.center.dy);

Path rightEarPath = Path();
rightEarPath.addOval(Rect.fromLTWH(- rightEarRect.width / 2, - rightEarRect.height / 2, rightEarRect.width, rightEarRect.height));
rightEarPath = rightEarPath.transform(Matrix4.rotationZ(pi/10).storage);
canvas.drawPath(rightEarPath, _paint);
canvas.restore();

首先调用 canvas.save() 将画布保存,根据耳朵的起始点创建一个 Rect ,然后将画布移动到该 Rect 的中心点,创建一个 Path 并添加一个椭圆,椭圆的 Rect 就是上面创建的 Rect ,然后将 Path 以 Z 轴旋转一定角度使其与耳朵的形状保持一个方向,然后将 Path 绘制出来,最后调用 canvas.restore() 恢复画布。右耳的填充同理,最终实现效果如下:

image-20220324222243828

腮红

腮红的绘制很简单,就是在脸的左右两边各绘制一个粉色的圆,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_paint.color = Color(0xFFE79EC3);

var pointion1 = leftBodyPoints.first;
Path leftFacePath = Path();
Rect leftFaceRect = Rect.fromLTWH(position1.dx - 25.w - 15.w, position1.dy + 80.w - 15.w, 30.w, 30.w);
leftFacePath.addOval(leftFaceRect);
canvas.drawPath(leftFacePath, _paint);


var rightFirstPosition = rightBodyPath.getPositionFromPercent(0);
Path rightFacePath = Path();
Rect rightFaceRect = Rect.fromLTWH(rightFirstPosition.dx + 25.w - 15.w, rightFirstPosition.dy + 80.w - 15.w, 30.w, 30.w);
rightFacePath.addOval(rightFaceRect);
canvas.drawPath(rightFacePath, _paint);

在脸的位置创建一个 30.w 的正方形 Rect ,然后通过 Path 添加一个椭圆最后绘制出来即可,实现效果如下:

image-20220324222311829

胡萝卜

萝卜的填充分为两步,萝卜叶和萝卜体,萝卜叶的填充相对比较简单,只需使用填充模式绘制萝卜叶的 Path 即可,代码如下:

1
2
3
_paint.style = PaintingStyle.fill;
_paint.color = Colors.green;
canvas.drawPath(radishLeafPath, _paint);

效果如下:

萝卜体的填充需要先构建出萝卜体的 Path ,之前绘制萝卜体时时多个 Path 组合而成的,要对其填充首先要将这多个 Path 合成一个 Path,代码如下:

1
2
3
4
5
6
7
8
9
Path radishBorderPath = Path();
var radishFistPosition = radishTopPath.getPositionFromPercent(0);
radishBorderPath
..moveTo(radishFistPosition.dx, radishFistPosition.dy)
..addPointsFromPath(radishTopPath)
..addPointsFromPath(rightHandsFeetPath)
..addPointsFromPath(radishBottomPath, isReverse: true)
..addPointsFromPath(leftHandsFeetPath, isReverse: true)
..close();

同样采用的是 addPointsFromPath 方法,将顶部、底部的曲线以及左右手脚的线条合并为一个 Path,再对该 Path 进行填充绘制即可得到胡萝卜的效果,绘制代码如下:

1
2
3
_paint.style = PaintingStyle.fill;
_paint.color = Colors.orange;
canvas.drawPath(radishBorderPath, _paint);

效果如下:

image-20220324222440217

这样一个可爱的抱着胡萝卜的小白兔效果就绘制完成了。

动画

图形绘制完成后接下来就是添加动画效果,动画效果分为两部分:线条的绘制动画和颜色的填充动画。动画的绘制使用 AnimationController 结合 CustomPainter 来实现。通过 Animation 的不同进度绘制 Path 的不同长度,从而实现动画效果。

线条绘制动画

要实现线条的动画效果,即线条的动态绘制,需要计算 Path 的路径点,然后根据动画的进度动态绘制 Path 的长度,由于这里 Path 比较多,如果每个都单独使用一个动画来控制的话太麻烦了,所以这里将所有的绘制线条的 Path 放到一个集合里用一个动画来控制。

首先在使用 RabbitPainter 的 Widget 中创建动画:

1
2
var bodyAniamtion = AnimationController(vsync: this, upperBound: 15.0)
..duration = const Duration(seconds: 10);

创建一个 AnimationController ,设置动画的最大值为 15,为什么是 15 ?,因为 Path 的个数有 15 个,所以这里设置动画的最大值为 15 , 然后设置动画时长为 10 秒。

在 RabbitPainter 中使用:

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
36
/// 将所有线条path放入集合进行统一绘制
var list = [
leftBodyPath,
rightBodyPath,
earPath,
leftHandsFeetPath,
rightHandsFeetPath,
radishLeafPath,
mouthPath,
leftEyesPath,
rightEyesPath,
radishTopPath,
radishBottomPath,
radishBodyPath1,
radishBodyPath2,
radishBodyPath3,
tailPath,
];


///绘制整体边框动画
void drawBorder(Canvas canvas, List<Path> list){
int index = (bodyAnimation.value as double) ~/ 1 ;
double progress = bodyAnimation.value % 1;
for(int i = 0 ; i < index; i++){
var path = list[i];
canvas.drawPath(path, _paint);
}
if(index >= list.length){
return;
}
var path = list[index];
var pms = path.computeMetrics();
var pm = pms.first;
canvas.drawPath(pm.extractPath(0, progress * pm.length), _paint);
}

首先将所有 Path 放到 list 集合里,然后调用 drawBorder 方法,在 drawBorder 方法里,首先获取动画的值,并用动画的值除以 1 取整,即获取当前动画执行到绘制那个 Path,然后用动画的值除以 1 取余数,即获取当前 Path 绘制的进度。获取到这两个值后先将已绘制完成的 Path 调用 canvas.drawPath 完整的绘制出来,然后取出当前正在绘制的 Path,计算 Path 的路径点,然后使用 extractPath 根据动画进度获取当前绘制的长度,将其绘制出来,就形成了动态绘制的效果,效果如下:

radishBody

颜色填充动画

填充动画如果还是按照线条动画的逻辑来进行绘制则效果不太好,这里采用另一种办法,获取填充 Path 的 Bounds 即 Path 的范围,通过 Path 的 getBounds 获取,获取的值是一个 Rect 类型即矩形,然后采用画布的裁剪,先对画布进行 Path 路径的裁剪,然后再绘制 Rect 矩形的填充,此时就可以根据进度改变填充 Rect 的高度来实现动态填充效果,原理示意图如下:

radishFillDemo2

如上,要绘制一个圆的填充,可以先绘制圆的边框,然后绘制圆范围矩形的填充,在通过画布的裁剪将多余部分去除。

根据以上原理来实现对整体白色填充的动画,首先创建填充动画的 Animation:

1
2
var fillBodyAnimation = AnimationController(vsync: this)
..duration = const Duration(seconds: 1);

然后进行填充代码的修改:

1
2
3
4
5
6
/// 绘制整体白色填充
canvas.save();
canvas.clipPath(bodyBorderPath);
var bodyRect = bodyBorderPath.getBounds();
canvas.drawRect(bodyRect.clone(height: bodyRect.height * fillAnimation.value), _paint);
canvas.restore();

因为要对画布进行裁剪,防止影响到其他的绘制,这里先调用 canvas.save() 对画布进行保存,然后调用 clipPath 对画布进行裁剪,即此时画布只保留 Path 路径区域。调用 Path 的 getBounds 方法获取 Path 所在区域的 Rect ,再调用 drawRect 方法进行填充绘制,绘制的 Rect 高度根据动画进度进行计算,最后调用 canvas.restore() 恢复画布。

这里用到了 Rect 的 clone 方法,该方法为自定义扩展 Rect 的方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension RectExt on Rect{

Rect clone({
double? left,
double? top,
double? right,
double? bottom,
double? width,
double? height}){

if(right != null || bottom != null){
return Rect.fromLTRB(left ?? this.left, top ?? this.top, right ?? this.right, bottom ?? this.bottom);
}

if(width != null || height != null){
return Rect.fromLTWH(left ?? this.left, top ?? this.top, width ?? this.width, height ?? this.height);
}

return Rect.fromLTRB(this.left, this.top, this.right, this.bottom);
}

}

主要作用是对 Rect 克隆,并修改其中某一个属性。

其余耳朵、腮红、萝卜的填充效果原理跟上面一样,就不一一的贴代码了,看一下最终填充动画的效果图:

radishFill

总结

整个小白兔的效果主要通过绘制 7 个 “3” 的形状组合而成,涉及到的技术主要是对 Path 和 Canvas 的使用,包括使用 Path 的贝塞尔曲线绘制 “3” 的形状,使用 Path 路径的计算获取 Path 上指定的点或段,通过 Path 的计算实现动态绘制的动画以及画布的裁剪和平移等。通过对 Path 和 Canvas 的灵活使用最终实现我们想要的效果。

源码地址:flutter_rabbit

 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!