Flutter学习:使用CustomPaint绘制图片

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!),
    ),
  ),
),

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();
  },
),

image

绘制网络图片

解析网络图片地址:

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);
  },
),

image

以上方法虽然能顺利绘制处图片,但是还有一个致命的缺点,那就是不能绘制处动态图片。

绘制动态图片 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);

image

drawImageRect 绘制图片一共需要3个步骤:

  1. 在一个以图片原始大小的坐标轴上,以图片左上角为原点,绘制 src 定义的矩形,截取 src 部分的图片内容(如图中黄色透明部分)

  2. 在屏幕上绘制 dst 定义的矩形(如图中灰色部分)

    image

  3. 把 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());

image

上图依次为以上三种情况绘制出来的图片显示的效果。由此可见,不管 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());

image

上图就是绘制的最终结果。这种结果是怎么来的呢?

  1. 先通过 dst 在页面绘制出图片的位置和大小

  2. 通过 center 绘制的矩形将图片划分为9个区域

image

上图中,浅蓝色的就是 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:最简单实现变形的方法

该对象的难点就在于scosssin的值应该怎么填才能符合我们的预期。搞了很久也没弄明白,但如果你只是想计算正弦值和余弦值可以使用以下方法:

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坐标

image

知道了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);

绘制图片的流程如下:

  1. 先将准备好的图片绘制在页面中
  2. 通过rects中的矩形来裁剪图片中的某个部位
  3. 将裁剪的图片通过transforms的变形显示出来

image

colorsblendMode就不演示了。我们来看一下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);

image

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);

image

PictureRecorder对象有一个isRecording属性用来检查当前是否正在记录命令。具体来说,如果已创建Canvas对象以记录命令并且尚未通过调用endRecording结束录制,则返回 true;如果此PictureRecorder尚未与Canvas关联,或者已调用endRecording方法,则返回 false 。

posted @ 2022-04-04 10:04  菠萝橙子丶  阅读(1503)  评论(0编辑  收藏  举报