對(duì)于一些復(fù)雜或不規(guī)則的 UI,我們可能無(wú)法通過(guò)組合其它組件的方式來(lái)實(shí)現(xiàn),比如我們需要一個(gè)正六邊形、一個(gè)漸變的圓形進(jìn)度條、一個(gè)棋盤(pán)等。當(dāng)然,有時(shí)候我們可以使用圖片來(lái)實(shí)現(xiàn),但在一些需要?jiǎng)討B(tài)交互的場(chǎng)景靜態(tài)圖片也是實(shí)現(xiàn)不了的,比如要實(shí)現(xiàn)一個(gè)手寫(xiě)輸入面板,這時(shí),我們就需要來(lái)自己繪制 UI 外觀。
幾乎所有的 UI 系統(tǒng)都會(huì)提供一個(gè)自繪 UI 的接口,這個(gè)接口通常會(huì)提供一塊 2D 畫(huà)布Canvas
,Canvas
內(nèi)部封裝了一些基本繪制的 API,開(kāi)發(fā)者可以通過(guò)Canvas
繪制各種自定義圖形。在 Flutter 中,提供了一個(gè)CustomPaint
組件,它可以結(jié)合畫(huà)筆CustomPainter
來(lái)實(shí)現(xiàn)自定義圖形繪制。
我們看看CustomPaint
構(gòu)造函數(shù):
CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child, //子節(jié)點(diǎn),可以為空
})
painter
: 背景畫(huà)筆,會(huì)顯示在子節(jié)點(diǎn)后面;foregroundPainter
: 前景畫(huà)筆,會(huì)顯示在子節(jié)點(diǎn)前面size
:當(dāng) child 為 null 時(shí),代表默認(rèn)繪制區(qū)域大小,如果有 child 則忽略此參數(shù),畫(huà)布尺寸則為 child 尺寸。如果有 child 但是想指定畫(huà)布為特定大小,可以使用 SizeBox 包裹 CustomPaint 實(shí)現(xiàn)。isComplex
:是否復(fù)雜的繪制,如果是,F(xiàn)lutter 會(huì)應(yīng)用一些緩存策略來(lái)減少重復(fù)渲染的開(kāi)銷(xiāo)。willChange
:和isComplex
配合使用,當(dāng)啟用緩存時(shí),該屬性代表在下一幀中繪制是否會(huì)改變。
可以看到,繪制時(shí)我們需要提供前景或背景畫(huà)筆,兩者也可以同時(shí)提供。我們的畫(huà)筆需要繼承CustomPainter
類,我們?cè)诋?huà)筆類中實(shí)現(xiàn)真正的繪制邏輯。
如果CustomPaint
有子節(jié)點(diǎn),為了避免子節(jié)點(diǎn)不必要的重繪并提高性能,通常情況下都會(huì)將子節(jié)點(diǎn)包裹在RepaintBoundary
組件中,這樣會(huì)在繪制時(shí)就會(huì)創(chuàng)建一個(gè)新的繪制層(Layer),其子組件將在新的 Layer 上繪制,而父組件將在原來(lái) Layer 上繪制,也就是說(shuō)RepaintBoundary
子組件的繪制將獨(dú)立于父組件的繪制,RepaintBoundary
會(huì)隔離其子節(jié)點(diǎn)和CustomPaint
本身的繪制邊界。示例如下:
CustomPaint(
size: Size(300, 300), //指定畫(huà)布大小
painter: MyPainter(),
child: RepaintBoundary(child:...)),
)
CustomPainter
中提定義了一個(gè)虛函數(shù)paint
:
void paint(Canvas canvas, Size size);
paint
有兩個(gè)參數(shù):
Canvas
:一個(gè)畫(huà)布,包括各種繪制方法,我們列出一下常用的方法:API名稱 | 功能 |
---|---|
drawLine | 畫(huà)線 |
drawPoint | 畫(huà)點(diǎn) |
drawPath | 畫(huà)路徑 |
drawImage | 畫(huà)圖像 |
drawRect | 畫(huà)矩形 |
drawCircle | 畫(huà)圓 |
drawOval | 畫(huà)橢圓 |
drawArc | 畫(huà)圓弧 |
Size
:當(dāng)前繪制區(qū)域大小。
現(xiàn)在畫(huà)布有了,我們最后還缺一個(gè)畫(huà)筆,F(xiàn)lutter 提供了Paint
類來(lái)實(shí)現(xiàn)畫(huà)筆。在Paint
中,我們可以配置畫(huà)筆的各種屬性如粗細(xì)、顏色、樣式等。如:
var paint = Paint() //創(chuàng)建一個(gè)畫(huà)筆并配置其屬性
..isAntiAlias = true //是否抗鋸齒
..style = PaintingStyle.fill //畫(huà)筆樣式:填充
..color=Color(0x77cdb175);//畫(huà)筆顏色
更多的配置屬性讀者可以參考Paint類定義。
下面我們通過(guò)一個(gè)五子棋游戲中棋盤(pán)和棋子的繪制來(lái)演示自繪UI的過(guò)程,首先我們看一下我們的目標(biāo)效果,如圖10-3所示:
代碼:
import 'package:flutter/material.dart';
import 'dart:math';
class CustomPaintRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: CustomPaint(
size: Size(300, 300), //指定畫(huà)布大小
painter: MyPainter(),
),
);
}
}
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
double eWidth = size.width / 15;
double eHeight = size.height / 15;
//畫(huà)棋盤(pán)背景
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0x77cdb175); //背景為紙黃色
canvas.drawRect(Offset.zero & size, paint);
//畫(huà)棋盤(pán)網(wǎng)格
paint
..style = PaintingStyle.stroke //線
..color = Colors.black87
..strokeWidth = 1.0;
for (int i = 0; i <= 15; ++i) {
double dy = eHeight * i;
canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paint);
}
for (int i = 0; i <= 15; ++i) {
double dx = eWidth * i;
canvas.drawLine(Offset(dx, 0), Offset(dx, size.height), paint);
}
//畫(huà)一個(gè)黑子
paint
..style = PaintingStyle.fill
..color = Colors.black;
canvas.drawCircle(
Offset(size.width / 2 - eWidth / 2, size.height / 2 - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
//畫(huà)一個(gè)白子
paint.color = Colors.white;
canvas.drawCircle(
Offset(size.width / 2 + eWidth / 2, size.height / 2 - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
}
//在實(shí)際場(chǎng)景中正確利用此回調(diào)可以避免重繪開(kāi)銷(xiāo),本示例我們簡(jiǎn)單的返回true
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
繪制是比較昂貴的操作,所以我們?cè)趯?shí)現(xiàn)自繪控件時(shí)應(yīng)該考慮到性能開(kāi)銷(xiāo),下面是兩條關(guān)于性能優(yōu)化的建議:
shouldRepaint
返回值;在 UI 樹(shù)重新 build 時(shí),控件在繪制前都會(huì)先調(diào)用該方法以確定是否有必要重繪;假如我們繪制的 UI 不依賴外部狀態(tài),那么就應(yīng)該始終返回false
,因?yàn)橥獠繝顟B(tài)改變導(dǎo)致重新 build 時(shí)不會(huì)影響我們的 UI 外觀;如果繪制依賴外部狀態(tài),那么我們就應(yīng)該在shouldRepaint
中判斷依賴的狀態(tài)是否改變,如果已改變則應(yīng)返回true
來(lái)重繪,反之則應(yīng)返回false
不需要重繪。shouldRepaint
回調(diào)值為false
,然后將棋盤(pán)組件作為背景。然后將棋子的繪制放到另一個(gè)組件中,這樣每次落子時(shí)只需要繪制棋子。
自繪控件非常強(qiáng)大,理論上可以實(shí)現(xiàn)任何2D圖形外觀,實(shí)際上 Flutter 提供的所有組件最終都是通過(guò)調(diào)用 Canvas 繪制出來(lái)的,只不過(guò)繪制的邏輯被封裝起來(lái)了,讀者有興趣可以查看具有外觀樣式的組件源碼,找到其對(duì)應(yīng)的RenderObject
對(duì)象,如Text
對(duì)應(yīng)的RenderParagraph
對(duì)象最終會(huì)通過(guò)Canvas
實(shí)現(xiàn)文本繪制邏輯。下一節(jié)我們會(huì)再通過(guò)一個(gè)自繪的圓形背景漸變進(jìn)度條的實(shí)例來(lái)幫助讀者加深印象。
更多建議: