# CustomPaint與Canvas
對于一些復雜或不規則的UI,我們可能無法使用現有Widget組合的方式來實現,比如我們需要一個正六邊形、一個漸變的圓形進度條、一個棋盤等,當然有時候我們可以使用圖片來實現,但在一些需要動態交互的場景靜態圖片是實現不了的,比如要實現一個手寫輸入面板。這時,我們就需要來自己繪制UI外觀。
幾乎所有的UI系統都會提供一個自繪UI的接口,這個接口通常會提供一塊2D畫布Canvas,Canvas內部封裝了一些基本繪制的API,開發者可以通過Canvas繪制各種自定義圖形。在Flutter中,提供了一個CustomPaint Widget,它可以結合一個畫筆CustomPainter來實現繪制自定義圖形。
### CustomPaint
我們看看CustomPaint構造函數:
```
const CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child, //子節點,可以為空
})
```
- painter: 背景畫筆,會顯示在子節點后面;
- foregroundPainter: 前景畫筆,會顯示在子節點前面
- size:當child為null時,代表默認繪制區域大小,如果有child則忽略此參數,畫布尺寸則為child尺寸。如果有child但是想指定畫布為特定大小,可以使用SizeBox包裹CustomPaint實現。
- isComplex:是否復雜的繪制,如果是,Flutter會應用一些緩存策略來減少重復渲染的開銷。
- willChange:和isComplex配合使用,當啟用緩存時,該屬性代表在下一幀中繪制是否會改變。
可以看到,繪制時我們需要提供前景或背景畫筆,兩者也可以同時提供。我們的畫筆需要繼承CustomPainter類,我們在畫筆類中實現真正的繪制邏輯。
#### 注意
如果CustomPaint有子節點,為了避免子節點不必要的重繪并提高性能,通常情況下都會將子節點包裹在RepaintBoundary Widget中,這樣會在繪制時創建一個新的繪制層(Layer),其子Widget將在新的Layer上繪制,而父Widget將在原來Layer上繪制,也就是說RepaintBoundary 子Widget的繪制將獨立于父Widget的繪制,RepaintBoundary會隔離其子節點和CustomPaint本身的繪制邊界。示例如下:
```
CustomPaint(
size: Size(300, 300), //指定畫布大小
painter: MyPainter(),
child: RepaintBoundary(child:...)),
)
```
### CustomPainter
CustomPainter中提定義了一個虛函數`paint`:
```
void paint(Canvas canvas, Size size);
```
`paint`有兩個參數:
- Canvas:一個畫布,包括各種繪制方法,我們列出一下常用的方法:
|API名稱 | 功能 | | ---------- | ------ | | drawLine | 畫線 | | drawPoint | 畫點 | | drawPath | 畫路徑 | | drawImage | 畫圖像 | | drawRect | 畫矩形 | | drawCircle | 畫圓 | | drawOval | 畫橢圓 | | drawArc | 畫圓弧 |
- Size:當前繪制區域大小。
### 畫筆Paint
現在畫布有了,我們最后還缺一個畫筆,Flutter提供了Paint類來實現畫筆。在Paint中,我們可以配置畫筆的各種屬性如粗細、顏色、樣式等。如:
```
var paint = Paint() //創建一個畫筆并配置其屬性
..isAntiAlias = true //是否抗鋸齒
..style = PaintingStyle.fill //畫筆樣式:填充
..color=Color(0x77cdb175);//畫筆顏色
```
更多的配置屬性讀者可以參考Paint類定義。
## 示例:五子棋/盤
下面我們通過一個五子棋游戲中棋盤和棋子的繪制來演示自繪UI的過程,首先我們看一下我們的目標結果:

代碼:
```
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), //指定畫布大小
painter: MyPainter(),
),
);
}
}
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
double eWidth = size.width / 15;
double eHeight = size.height / 15;
//畫棋盤背景
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0x77cdb175); //背景為紙黃色
canvas.drawRect(Offset.zero & size, paint);
//畫棋盤網格
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);
}
//畫一個黑子
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,
);
//畫一個白子
paint.color = Colors.white;
canvas.drawCircle(
Offset(size.width / 2 + eWidth / 2, size.height / 2 - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
}
//在實際場景中正確利用此回調可以避免重繪開銷,本示例我們簡單的返回true
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
```
### 性能
繪制是比較昂貴的操作,所以我們在實現自繪控件時應該考慮到性能開銷,下面是兩條關于性能優化的建議:
- 盡可能的利用好`shouldRepaint`返回值;在UI樹重新build時,控件在繪制前都會先調用該方法以確定是否有必要重繪;假如我們繪制的UI不依賴外部狀態,那么就應該始終返回false,因為外部狀態改變導致重新build時不會影響我們的UI外觀;如果繪制依賴外部狀態,那么我們就應該在shouldRepaint中判斷依賴的狀態是否改變,如果已改變則應返回`true`來重繪,反之則應返回`false`不需要重繪。
- 繪制盡可能多的分層;在上面五子棋的示例中,我們將棋盤和棋子的繪制放在了一起,這樣會有一個問題:由于棋盤始終是不變的,用戶每次落子時變的只是棋子,但是如果按照上面的代碼來實現,每次繪制棋子時都要重新繪制一次棋盤,這是沒必要的。優化的方法就是將棋盤單獨抽為一個Widget,并設置其`shouldRepaint`回調值為false,然后將棋盤Widget作為背景。然后將棋子的繪制放到另一個Widget中,這樣落子時只需要繪制棋子。
## 總結
自繪控件非常強大,理論上可以實現任何2D圖形外觀,實際上Flutter提供的所有Widget最終都是調用Canvas繪制出來的,只不過繪制的邏輯被封裝起來了,讀者有興趣可以查看具有外觀樣式的Widget的源碼,找到其對應的RenderObject對象,如Text Widget最終會通過RenderParagraph對象來通過Canvas實現文本繪制邏輯。
下一節我們再通過實現一個自繪的圓形漸變進度條的實例來幫助讀者加深印象。
- 緣起
- 起步
- 移動開發技術簡介
- Flutter簡介
- 搭建Flutter開發環境
- 常見配置問題
- Dart語言簡介
- 第一個Flutter應用
- 計數器示例
- 路由管理
- 包管理
- 資源管理
- 調試Flutter APP
- Dart線程模型及異常捕獲
- 基礎Widgets
- Widget簡介
- 文本、字體樣式
- 按鈕
- 圖片和Icon
- 單選框和復選框
- 輸入框和表單
- 布局類Widgets
- 布局類Widgets簡介
- 線性布局Row、Column
- 彈性布局Flex
- 流式布局Wrap、Flow
- 層疊布局Stack、Positioned
- 容器類Widgets
- Padding
- 布局限制類容器ConstrainedBox、SizeBox
- 裝飾容器DecoratedBox
- 變換Transform
- Container容器
- Scaffold、TabBar、底部導航
- 可滾動Widgets
- 可滾動Widgets簡介
- SingleChildScrollView
- ListView
- GridView
- CustomScrollView
- 滾動監聽及控制ScrollController
- 功能型Widgets
- 導航返回攔截-WillPopScope
- 數據共享-InheritedWidget
- 主題-Theme
- 事件處理與通知
- 原始指針事件處理
- 手勢識別
- 全局事件總線
- 通知Notification
- 動畫
- Flutter動畫簡介
- 動畫結構
- 自定義路由過渡動畫
- Hero動畫
- 交錯動畫
- 自定義Widget
- 自定義Widget方法簡介
- 通過組合現有Widget實現
- 實例:TurnBox
- CustomPaint與Canvas
- 實例:圓形漸變進度條(自繪)
- 文件操作與網絡請求
- 文件操作
- Http請求-HttpClient
- Http請求-Dio package
- 實例:Http分塊下載
- WebSocket
- 使用Socket API
- Json轉Model
- 包與插件
- 開發package
- 插件開發:平臺通道簡介
- 插件開發:實現Android端API
- 插件開發:實現IOS端API
- 系統能力調用
- 國際化
- 讓App支持多語言
- 實現Localizations
- 使用Intl包
- Flutter核心原理
- Flutter UI系統
- Element和BuildContext
- RenderObject與RenderBox
- Flutter從啟動到顯示