簡介
上一篇我們介紹了使用 Flutter 的 Canvas 繪制基本圖形的示例,簡單的示例沒什么好玩的,今天這一篇我們來點(diǎn)有趣的,我們會(huì)完成如下圖形的繪制:
- 發(fā)現(xiàn)數(shù)學(xué)重復(fù)之美:使用等邊三角形組合成彩虹傘面。
- 繪制彩虹。
- 繪制評(píng)分用的五角星。
通過這一篇,我們可以知道自定義形狀繪制的基本原理,然后可以在這個(gè)基礎(chǔ)上繪制你自己想要繪制的圖形。
等邊三角形構(gòu)建重復(fù)之美
首先我們來繪制等邊三角形,其實(shí)上一篇我們也有繪制等邊三角形,只是那是將三個(gè)頂點(diǎn)手動(dòng)計(jì)算出來的,這一篇我們封裝一個(gè)繪制等邊三角形的通用方法。老規(guī)矩,先定義方法的輸入?yún)?shù),如下所示:
-
canvas:Canvas畫布 -
color:繪制顏色 -
startVertex:三角形的第一個(gè)頂點(diǎn)位置,這里我們其他邊都是相對(duì)這個(gè)點(diǎn)旋轉(zhuǎn)的 -
length:邊長 -
startAngle:第一條邊相對(duì)水平方向旋轉(zhuǎn)的夾角,這樣我們可以改變夾角來更改三角形的繪制位置。 -
clockwise:順時(shí)針繪制,如果是順時(shí)針,則繪制的偏移夾角往順時(shí)針方向開始,否則逆時(shí)針。 -
filled:是否填充圖形。
void drawEquilateralTriangle(
Canvas canvas, {
required Color color,
required Offset startVertex,
required double length,
double startAngle = 0,
clockwise = true,
filled = true,
})
等邊三角形基于一個(gè)頂點(diǎn),一條邊和起始角度后就可以計(jì)算其他兩個(gè)頂點(diǎn)的位置,具體推到通過三角函數(shù)就可以了。

具體計(jì)算三角形的三個(gè)頂點(diǎn)的方法如下,這里逆時(shí)針方向和順時(shí)針方向的計(jì)算方式有點(diǎn)不同,需要區(qū)分一下。
static List<Offset> getEquilateralTriangleVertexes(
Offset startVertex, double length,
{double startAngle = 0, bool clockwise = true}) {
double point2X, point2Y, point3X, point3Y;
point2X = startVertex.dx + length * cos(startAngle);
point2Y = startVertex.dy - length * sin(startAngle);
if (clockwise) {
point3X = startVertex.dx + length * cos(pi / 3 + startAngle);
point3Y = startVertex.dy - length * sin(pi / 3 + startAngle);
} else {
point3X = startVertex.dx + length * cos(pi / 3 - startAngle);
point3Y = startVertex.dy + length * sin(pi / 3 - startAngle);
}
return [startVertex, Offset(point2X, point2Y), Offset(point3X, point3Y)];
}
有了頂點(diǎn)我們就可以使用 Path 將頂點(diǎn)連起來就完成等邊三角形的繪制了,繪制三角形的實(shí)現(xiàn)方法如下:
void drawEquilateralTriangle(
Canvas canvas, {
required Color color,
required Offset startVertex,
required double length,
double startAngle = 0,
clockwise = true,
filled = true,
}) {
assert(length > 0);
Path trianglePath = Path();
List<Offset> vertexes = ShapesUtil.getEquilateralTriangleVertexes(
startVertex,
length,
clockwise: clockwise,
startAngle: startAngle,
);
trianglePath.moveTo(vertexes[0].dx, vertexes[0].dy);
for (int i = 1; i < vertexes.length; i++) {
trianglePath.lineTo(vertexes[i].dx, vertexes[i].dy);
}
trianglePath.close();
Paint paint = Paint();
paint.color = color;
if (!filled) {
paint.style = PaintingStyle.stroke;
}
canvas.drawPath(trianglePath, paint);
}
}
單獨(dú)一個(gè)三角形沒啥意思,我們通過畫6個(gè)等邊三角形,每個(gè)三角形旋轉(zhuǎn)60度,空心繪制看看怎么樣?

一個(gè) 完美的六邊形出來了,再試試12個(gè)怎么樣。

形狀越多,會(huì)越接近圓形,你會(huì)充分發(fā)現(xiàn)對(duì)稱之美。下面是我們用24個(gè)三角形,填充不同顏色后的效果。有點(diǎn)像一把彩虹傘的傘面了,感覺是不是很美?

上面圖形的實(shí)現(xiàn)代碼如下,其中顏色是通過一個(gè)顏色數(shù)組完成的。
int number = 24;
for (int i = 0; i < number; ++i) {
drawEquilateralTriangle(
canvas,
color: colors[i],
startVertex: Offset(center.width, center.height),
length: 120,
startAngle: i * 2 * pi / number,
clockwise: true,
filled: true,
);
}
繪制彩虹
有了上面的彩虹傘一樣的啟發(fā),我們決定來繪制彩虹。彩虹其實(shí)比較簡單,繪制7條不同顏色的弧線即可。這里講一下弧線的繪制約束。如下圖所示,實(shí)際上弧線是通過矩形的內(nèi)接橢圓限制的(這里用正方形,內(nèi)接為圓形示例)。外面的矩形限制了橢圓位置和尺寸,而通過 startAngle (起始角度)和 sweepAngle (弧線覆蓋的角度范圍)就能夠確定弧線的起點(diǎn)和終點(diǎn),從而得到一段弧線。注意的是,數(shù)學(xué)里我們是逆時(shí)針角度為正,但是在 Flutter 默認(rèn)是順時(shí)針為正,因此如果你要從逆時(shí)針方向開始角度就要設(shè)置為負(fù)數(shù)。

下面是弧線繪制的示例代碼:
Path path1 = Path();
Rect rect1 = Rect.fromLTWH(startPoint.dx + (width - innerWidth) / 2,
startPoint.dy + (width - innerWidth) / 2, innerWidth, innerWidth);
path1.arcTo(rect1, -pi / 6, -2 * pi / 3, true);
paint.color = colors[i];
canvas.drawPath(path1, paint);
有了這個(gè)基礎(chǔ),我們通過循環(huán) ,繪制7條弧線,保證每條弧線挨著就行。而弧線的線條粗細(xì)可以用畫筆的寬度來搞定,代碼如下。我們這里每條弧線的中心、起始角度和覆蓋角度是一樣的,通過改變不同弧線的正方形邊長實(shí)現(xiàn)彩虹弧線的位置不同,然后畫筆粗細(xì)保持為每條彩虹的高度的一半就可以保證每條彩虹是挨著的了。
void drawRainbow(
Canvas canvas, {
required Offset startPoint,
required double width,
}) {
assert(width > 0);
var paint = Paint();
double rowHeight = 12;
paint.strokeWidth = rowHeight / 2;
List<Color> colors = [
Color(0xFFE05100),
Color(0xFFF0A060),
Color(0xFFE0E000),
Color(0xFF10F020),
Color(0xFF2080F5),
Color(0xFF104FF0),
Color(0xFFA040E5),
];
paint.style = PaintingStyle.stroke;
for (var i = 0; i < 7; i++) {
double innerWidth = width - i * rowHeight;
Path path1 = Path();
Rect rect1 = Rect.fromLTWH(startPoint.dx + (width - innerWidth) / 2,
startPoint.dy + (width - innerWidth) / 2, innerWidth, innerWidth);
path1.arcTo(rect1, -pi / 6, -2 * pi / 3, true);
paint.color = colors[i];
canvas.drawPath(path1, paint);
}
}
最終效果如下圖所示。

繪制五角星
五角星相對(duì)來說會(huì)復(fù)雜一些,主要是要知道通過中心點(diǎn)確定10個(gè)頂點(diǎn)的坐標(biāo),這里就需要利用二維坐標(biāo)的旋轉(zhuǎn)公式了,具體可以查閱相關(guān)資料,結(jié)論是一個(gè)點(diǎn)(x2, y2)圍繞另一個(gè)點(diǎn)(x1, y1)旋轉(zhuǎn)某個(gè)角度(α)后得到的新坐標(biāo)(x, y)計(jì)算方式如下:


有了這個(gè)基礎(chǔ),我們就可以基于五角星的中心點(diǎn),第一個(gè)頂點(diǎn),邊長(間隔一個(gè)點(diǎn)連線的線段長度)來通過旋轉(zhuǎn)計(jì)算其他頂點(diǎn)了。其中外面5頂點(diǎn)一組計(jì)算,內(nèi)部5個(gè)頂點(diǎn)一組計(jì)算。最終獲取5個(gè)頂點(diǎn)的代碼如下:
static List<Offset> getStarVertexes(Offset center, double length) {
assert(length > 0);
// 外接圓半徑計(jì)算(五角星銳角為36度)
double radius = length / 2 / cos(18 / 180 * pi);
// 內(nèi)部頂點(diǎn)的半徑
double innerRadius =
radius / (cos(36 / 180 * pi) + sin(36 / 180 * pi) / sin(18 / 180 * pi));
List<Offset> vertexes = [];
Offset outerStartVertex = Offset(center.dx, center.dy - radius);
Offset innerStartVertex = Offset(
center.dx - innerRadius * sin(36 / 180 * pi),
center.dy - innerRadius * cos(36 / 180 * pi),
);
vertexes.add(outerStartVertex);
vertexes.add(innerStartVertex);
// 計(jì)算方式為以第一個(gè)頂點(diǎn)圍繞五角星中心點(diǎn)坐標(biāo)旋轉(zhuǎn)得到
const double rotateAngle = 72 / 180 * pi;
for (int i = 1; i < 5; ++i) {
vertexes.add(Offset(
center.dx +
(outerStartVertex.dx - center.dx) * cos(-i * rotateAngle) -
(outerStartVertex.dy - center.dy) * sin(-i * rotateAngle),
center.dy +
(outerStartVertex.dy - center.dy) * cos(-i * rotateAngle) +
(outerStartVertex.dx - center.dx) * sin(-i * rotateAngle),
));
vertexes.add(Offset(
center.dx +
(innerStartVertex.dx - center.dx) * cos(-i * rotateAngle) -
(innerStartVertex.dy - center.dy) * sin(-i * rotateAngle),
center.dy +
(innerStartVertex.dy - center.dy) * cos(-i * rotateAngle) +
(innerStartVertex.dx - center.dx) * sin(-i * rotateAngle),
));
}
return vertexes;
}
有了頂點(diǎn),繪制方式就和三角形一樣了,將頂點(diǎn)連起來就好了。下面是我們繪制了一個(gè)常見的五星評(píng)分的圖形。

總結(jié)
本篇介紹了基于 Flutter 的 CustomPaint 繪制定制化圖形的示例,可以看到,其實(shí)只要 UI 小姐姐給出的圖形能夠用數(shù)學(xué)表達(dá)式表示出來,都可以用 CustomPaint 的 Canvas 來實(shí)現(xiàn)。本篇代碼已上傳至:繪圖相關(guān)代碼。
當(dāng)然,如果 UI 小姐姐給了一個(gè)無法用數(shù)學(xué)表達(dá)式解決的形狀,那么你要往深處想想,小姐姐是不是看上你了!如果不是看上你,那么你可以大膽地回一句:“這個(gè)做不了,給我切個(gè)圖吧!”