Flutter學習:認識CustomPaint組件和Paint對象
Flutter學習:使用CustomPaint繪制路徑
Flutter學習:使用CustomPaint繪制圖形
Flutter學習:使用CustomPaint繪制文字
Flutter學習:使用CustomPaint繪制圖片
和 CustomPaint 繪制圖形相比,繪制圖片要麻煩一點。
繪制圖片的方法有6個:
- canvas.drawImage
- canvas.drawImageNine
- canvas.drawImageRect
- canvas.drawAtlas
- canvas.drawRawAtlas
- canvas.drawPicture
canvas.drawImage
該方法需要傳遞3個參數:
Image image:這里的 image 對象不是 material 庫里的 image 對象,而是 ui 庫里的 image 對象Offset offset:繪制的圖片左上角的位置坐標Paint paint:繪制的畫筆,可以給圖片添加其他屬性
繪制的 image 對象在 ui 庫里,所有先引用 ui 庫 :
import 'dart:ui' as ui;
創建一個空的 ui 里的 image 對象:
ui.Image? image;
創建繪制圖片的類:
class CustomImagePainter extends CustomPainter {
final ui.Image image;
CustomImagePainter(this.image);
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint();
canvas.drawImage(image, Offset.zero, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => this != oldDelegate;
}
繪制資源圖片
資源圖片是指在存在 assets 目錄下,並已在 pubspec.yaml 文件中注冊的圖片。
先把資源圖片變成 ui.Image 對象:
Future loadIamge(String path) async {
// 加載資源文件
final data = await rootBundle.load(path);
// 把資源文件轉換成Uint8List類型
final bytes = data.buffer.asUint8List();
// 解析Uint8List類型的數據圖片
final image = await decodeImageFromList(bytes);
this.image = image;
setState(() {});
}
使用該方法加載圖片:
ElevatedButton(
child: const Text('加載資源圖片'),
onPressed: () {
loadAssetImage('assets/images/sxt.jpg');
},
),
要想把圖片正確的顯示在頁面中,需要按如下組件配置:
FittedBox(
child: SizedBox(
width: image?.width.toDouble(),
height: image?.height.toDouble(),
child: CustomPaint(
painter: CustomImagePainter(image!),
),
),
),

繪制本地圖片
創建一個空的 XFile 對象,初始化 ImagePicker 對象:
XFile? localImage;
final ImagePicker imagePicker = ImagePicker();
把圖片加載成可繪制的對象:
Future loadLocalImage(String path) async {
// 通過字節的方式讀取本地文件
final bytes = await File(path).readAsBytes();
// 解析圖片資源
final image = await decodeImageFromList(bytes);
this.image = image;
setState(() {});
}
從手機文件中加載圖片:
Future pickLocalImage() async {
XFile? xImage = await imagePicker.pickImage(source: ImageSource.gallery);
if (xImage != null) {
localImage = xImage;
await loadLocalImage(localImage!.path);
}
setState(() {});
}
使用該方法:
ElevatedButton(
child: const Text('加載本地圖片'),
onPressed: () {
pickLocalImage();
},
),

繪制網絡圖片
解析網絡圖片地址:
Future loadNetImage(String path) async {
final data = await NetworkAssetBundle(Uri.parse(path)).load(path);
final bytes = data.buffer.asUint8List();
final image = await decodeImageFromList(bytes);
this.image = image;
setState(() {});
}
使用該方法:
ElevatedButton(
child: const Text('加載網絡圖片'),
onPressed: () {
loadNetImage(netImage);
},
),

以上方法雖然能順利繪制處圖片,但是還有一個致命的缺點,那就是不能繪制處動態圖片。
繪制動態圖片 TODO
如果想繪制動態圖片,需要使用 instantiateImageCodec。
instantiateImageCodec 方法可以傳入4個參數:
-
Uint8List:一個由圖片轉換成的 Uint8List 對象 -
bool allowUpscaling:通常應避免將圖像縮放到大於其固有大小,這會導致圖像使用過多不必要的內存。如果必須縮放圖像,則allowUpscaling參數必須設置為 true -
int? targetHeight:指定圖片被顯示出來的固定高度 -
int? targetWidth:指定圖片被顯示出來的固定寬度
這里以資源圖片為例,修改代碼如下:
Future loadAssetImage(String path) async {
final data = await rootBundle.load(path);
final bytes = data.buffer.asUint8List();
final codec = await ui.instantiateImageCodec(bytes);
final frameInfo = await codec.getNextFrame();
// print('幀數數量:${codec.frameCount}');
// print('持續時間:${frameInfo.duration * frameCount}');
image = frameInfo.image;
setState(() {});
}
現在代碼還不能繪制出動態圖片,只會獲取動態圖片的第一幀,所以這種方法也可以用來繪制靜態圖片。
思路:要想繪制動態圖片,需要不停的調用 codec.getNextFrame()方法,來傳值給 image對象。可以通過循環不停調用該方法,但是動態圖片刷新顯示的速度會很快,和原始圖片不同。具體代碼編寫目前沒有,有知道怎么操作的可以告知一下😁。
canvas.drawImageRect
將src參數描述的給定圖像的子集繪制到dst參數給定的軸對齊矩形中的畫布中。
該方法需要傳遞4個參數:
Image image:傳遞一個 ui.Image 對象Rect src:繪制一個矩形,用來裁剪圖片的某個位置,再顯示在dst所在的矩形中Rect dst:繪制一個矩形,用來顯示圖片在屏幕的位置和寬高Paint paint:繪制的畫筆,可以給圖片添加其他屬性
Paint paint = Paint();
Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
Rect dst = Rect.fromLTWH(0, 800, image.width.toDouble(), image.height.toDouble());
canvas.drawImageRect(image, src, dst, paint);

drawImageRect 繪制圖片一共需要3個步驟:
-
在一個以圖片原始大小的坐標軸上,以圖片左上角為原點,繪制 src 定義的矩形,截取 src 部分的圖片內容(如圖中黃色透明部分)
-
在屏幕上繪制 dst 定義的矩形(如圖中灰色部分)

-
把 src 截取的圖片以 dst 定義的矩形的左上角為頂點添加到矩形中顯示出來
注意:因代碼設置組件SizedBox的寬高為 image 的原始寬高,再添加一個父組件 FittedBox可以讓圖片以原始寬高通過縮放完整的顯示在界面,其實現在界面的寬高已經不是屏幕的邊界寬高,而是圖片的原始寬高。
當 dst 繪制的矩形比 src 截取的圖片小會怎么樣?
修改如下代碼:
Rect dst = Rect.fromLTWH(0, 800, image.width.toDouble() * 8 / 10, image.height.toDouble() * 8 / 10);
當 dst 繪制的矩形寬高和圖片原始比例不同會怎么樣?
修改如下代碼:
Rect dst = Rect.fromLTWH(0, 800, image.width.toDouble() * 8 / 10, image.height.toDouble());
當 src的截圖和圖片原始比例不同會怎么樣?
修改如下代碼:
Rect src = Rect.fromLTWH( 0, 0, image.width.toDouble() * 8 / 10, image.height.toDouble());

上圖依次為以上三種情況繪制出來的圖片顯示的效果。由此可見,不管 src 截取的圖片是多寬多高,總是會在 dst 繪制的矩形中完整的顯示出來,哪怕是是圖片寬高變形。
canvas.drawImageNine
把普通圖片繪制成點九圖片使用。需要傳遞4個參數:
mage image:傳遞一個 ui.Image 對象Rect center:繪制一個矩形,用來確定兩條水平線和兩條垂直線分割圖像Rect dst:繪制矩形用來確定圖片顯示的位置和大小Paint paint:繪制的畫筆,可以給圖片添加其他屬性
Rect center = Rect.fromCenter(center: size.center(Offset.zero), width: 300, height: 100);
Rect dst = Rect.fromCenter(
center: size.center(Offset.zero),
width: image.width.toDouble() * 6 / 7,
height: image.height.toDouble() * 3 / 5);
canvas.drawImageNine(image, center, dst, Paint());

上圖就是繪制的最終結果。這種結果是怎么來的呢?
-
先通過 dst 在頁面繪制出圖片的位置和大小
-
通過 center 繪制的矩形將圖片划分為9個區域

上圖中,淺藍色的就是 center 矩形,黃色的部分和 center 部分,都會因為外部條件使整體寬高變動而被拉伸,只有 dst 的4個角的區域的圖片會保持大小不變。
drawAtlas
將圖像的許多部分( atlas )繪畫到畫布上。當你想要在畫布上繪制圖像的許多部分時,例如使用精靈圖或縮放時,使用這種方法可以進行優化。它比使用多次調用drawImageRect更有效,並且提供了更多功能,可以通過單獨的旋轉或縮放來單獨轉換每個圖像部分,並使用純色混合或調制這些部分。
該方法需要傳遞7個參數:
Image atlas:ui.Image圖片對象List<RSTransform> transforms:由平移、旋轉和統一比例組成的變換。這是一種比完整矩陣更有效的方式來表示這些簡單的變換List<Rect> rects:矩形數組,用來確認裁剪圖片的位置和大小List<Color>? colors:混合模式時使用的顏色數組BlendMode? blendMode:混合模式Rect? cullRect: 可選的cullRect參數可以提供由要與剪輯進行比較的圖集的所有組件呈現的坐標邊界的估計值,以便在不相交時快速拒絕操作Paint paint:繪制的畫筆,可以給圖片添加其他屬性
transforms和rects列表的長度必須相等,如果colors參數不為 null,則它必須為空或與其他兩個列表具有相同的長度。
上面這些參數中,比較陌生的就是RSTransform,我們先來了解了解。
RSTransform
該對象有以下幾個屬性和方法:
double scos:旋轉的余弦值乘以比例因子。用來操作縮放double ssin:旋轉的正弦值乘以比例因子。用來操作旋轉double tx:平移的 x 坐標。后面的文字看不懂可以忽略(減去scos參數乘以旋轉點的 x 坐標,再加上ssin參數乘以旋轉點的 y 坐標)double ty:平移的 y 坐標。后面的文字看不懂可以忽略(減去ssin參數乘以旋轉點的 x 坐標,減去scos參數乘以旋轉點的 y 坐標)fromComponents:最簡單實現變形的方法
該對象的難點就在於scos和ssin的值應該怎么填才能符合我們的預期。搞了很久也沒弄明白,但如果你只是想計算正弦值和余弦值可以使用以下方法:
math.sin(度數 / 360 * 2 * math.pi)
如果你只是想移動位置不旋轉放大可以使用以下參數:
RSTransform(1, 0, 500, 500)
直接說簡單的fromComponents方法。
fromComponents
該方法有以下6個參數:
double rotation:旋轉的角度double scale:縮放的倍數double anchorX:旋轉點的x坐標double anchorY:旋轉點的y坐標double translateX:偏移的x坐標double translateY:偏移的y坐標

知道了RSTransform的用法,接下來的就簡單了。
drawAtlas的使用
List<RSTransform> transforms = [
RSTransform.fromComponents(
rotation: 0,
scale: 1,
anchorX: 0,
anchorY: 0,
translateX: 100,
translateY: 600,
),
RSTransform.fromComponents(
rotation: 0,
scale: 1,
anchorX: 0,
anchorY: 0,
translateX: 800,
translateY: 1800,
),
];
List<Rect> rects = [
Rect rect0 = const Rect.fromLTWH(500, 500, 500, 1000),
Rect rect1 = const Rect.fromLTWH(1000, 800, 800, 1200),
];
canvas.drawAtlas(image, transforms, rects, null, null, null, paint);
繪制圖片的流程如下:
- 先將准備好的圖片繪制在頁面中
- 通過rects中的矩形來裁剪圖片中的某個部位
- 將裁剪的圖片通過transforms的變形顯示出來

colors和blendMode就不演示了。我們來看一下cullRect這個參數。
cullRect可以用來驗證rects中的矩形是否在頁面內,如果有1個不在,drawAtlas方法將不會繪制任何內容。因為cullRect只接受1個Rect對象,所以必須一個一個的驗證。
drawRawAtlas
假如我們有一張500 * 2000的精靈圖,是由4個500 * 500的圖片組合而成。由drawAltas中對RSTransform的屬性解釋可得,精靈圖中每個圖片的旋轉中心點默認是左上角,所以,每張圖片的旋轉中心坐標分別是 (index * 500, 0)。
我們定義一個精靈圖的類來存儲這些信息。
class Sprite {
double centerX;
double centerY;
Sprite({require this.centerX, require this.centerY});
}
然后我們將那幾張圖的信息存儲在一個數組中:
List<Sprite> allSprite = [
Sprite(centerX: 0, centerY: 0),
Sprite(centerX: 500, centerY: 0),
Sprite(centerX: 1000, centerY: 0),
Sprite(centerX: 1500, centerY: 0),
];
使用Float32List組成一個矩形需要四個參數,我們有4張圖,所以需要4*4個參數:
Float32List rects = Float32List(allSprite.length * 4);
transforms的參數必須和rects的個數一樣:
Float32List transforms = Float32List(allSprite.length * 4);
RSTransform
接下來要做的就是把精靈圖中每張圖的信息存儲在上面定義的兩個數組中,這里默認使用的構造Rect的方法是Rect.fromLTRB:
for (var i = 0; i < allSprite.length; i++) {
final double rectX = i * 500.0; // 500是每張圖片的寬
rects[i * 4 + 0] = rectX; // 第i個矩形的Left
rects[i * 4 + 1] = 0.0; // 第i個矩形的Top
rects[i * 4 + 2] = rectX + 500.0; // 第i個矩形的Right
rects[i * 4 + 3] = 500.0; // 第i個矩形的Bottom
transforms[i * 4 + 0] = 1.0; // 第i個RSTransform的scos
transforms[i * 4 + 1] = 0.0; // 第i個RSTransform的ssin
transforms[i * 4 + 2] = allSprite[i].centerX; // 第i個RSTransform的tx
transforms[i * 4 + 3] = allSprite[i].centerY; // 第i個RSTransform的ty
}
現在可以直接繪制:
canvas.drawRawAtlas(image, transforms, rects, null, null, null, paint);

RSTransform.fromComponents
for (var i = 0; i < allSprite.length; i++) {
final double rectX = i * 500.0;
rects[i * 4 + 0] = rectX;
rects[i * 4 + 1] = 0.0;
rects[i * 4 + 2] = rectX + 500.0;
rects[i * 4 + 3] = 500.0;
final RSTransform rsTransform = RSTransform.fromComponents(
rotation: 0,
scale: 1,
anchorX: 0,
anchorY: 0,
translateX: allSprite[i].centerX,
translateY: allSprite[i].centerY + 500,
);
// 讓RSTransform自己轉換
transforms[i * 4 + 0] = rsTransform.scos;
transforms[i * 4 + 1] = rsTransform.ssin;
transforms[i * 4 + 2] = rsTransform.tx;
transforms[i * 4 + 3] = rsTransform.ty;
}
效果圖和RSTransform的一樣。
關於如何使用Int32List colors參數,可以查看這里。為了方便我將答案復制到下面。
我們先定義一個用來存儲顏色的Int32List:
Int32List colors = Int32List(4);
然后添加鏈接中的兩種方法:
方法一
int hexOfRGBA(int r, int g, int b, {double opacity = 1}) {
r = (r < 0) ? -r : r;
g = (g < 0) ? -g : g;
b = (b < 0) ? -b : b;
opacity = (opacity < 0) ? -opacity : opacity;
opacity = (opacity > 1) ? 255 : opacity * 255;
r = (r > 255) ? 255 : r;
g = (g > 255) ? 255 : g;
b = (b > 255) ? 255 : b;
int a = opacity.toInt();
return int.parse('0x${a.toRadixString(16)}${r.toRadixString(16)}${g.toRadixString(16)}${b.toRadixString(16)}');
}
方法二
int hexOfRGB(int r, int g, int b) {
r = (r < 0) ? -r : r;
g = (g < 0) ? -g : g;
b = (b < 0) ? -b : b;
r = (r > 255) ? 255 : r;
g = (g > 255) ? 255 : g;
b = (b > 255) ? 255 : b;
return int.parse('0xff${r.toRadixString(16)}${g.toRadixString(16)}${b.toRadixString(16)}');
}
使用:
colors[0] = hexOfRGBA(0,0,0,opacity: 0.7);
colors[1] = hexOfRGB(255, 255, 255);
canvas.drawPicture
將給定的圖片繪制到畫布上。只需要傳遞1個參數:
Picture picture:一個最終需要顯示的 Picture 對象
要想獲得Picture 對象,我們需要一個PictureRecorder對象。
ui.PictureRecorder recorder = ui.PictureRecorder();
然后重新定義一個Canvas對象:
ui.Canvas uiCanvas = ui.Canvas(recorder);
用重新定義的Canvas對象繪制我們想繪制的內容:
uiCanvas.drawCircle(size.center(Offset.zero), 100, paint);
繪制結束需要調用endRecording方法來獲取一個Picture對象:
ui.Picture picture = recorder.endRecording();
完整代碼:
Paint paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
ui.PictureRecorder recorder = ui.PictureRecorder();
ui.Canvas uiCanvas = ui.Canvas(recorder);
uiCanvas.drawCircle(size.center(Offset.zero), 100, paint);
ui.Picture picture = recorder.endRecording();
canvas.drawPicture(picture);

PictureRecorder對象有一個isRecording屬性用來檢查當前是否正在記錄命令。具體來說,如果已創建Canvas對象以記錄命令並且尚未通過調用endRecording結束錄制,則返回 true;如果此PictureRecorder尚未與Canvas關聯,或者已調用endRecording方法,則返回 false 。
