vlambda博客
学习文章列表

flutter画布绘制图片和文字

本节目标:

[1]. 了解如何获取 [ui.Image] 对象。
[2]. 将一张图片使用 Canvas 绘制出来。
[3]. 知道如何从图片中取出部分图片绘制到指定矩形域中。
[4]. 了解 Canvas 绘制图集的操作。
[5]. 如何在 Canvas 中绘制文字,并完善坐标系刻度。

一、图片绘制:

image-20201030110334887
绘制图片需要的是 ui.Image,需要异步加载,这里用 loadImageFromAssets 处理。
PaperPainter 接收 ui.Image 对象。在异步加载完成,刷新传入即可。

0. 如何从 assets 中获取图片数据。

通过 decodeImageFromList 方法可以将一个字节流转换为ui.Image 对象。将 assets 的文件读取为字节流可以使用 rootBundle.load 方法。

//读取 assets 中的图片
Future<ui.Image> loadImageFromAssets(String path) async {
  ByteData data = await rootBundle.load(path);
  List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
  return decodeImageFromList(bytes);
}

1. 基础图片绘制:drawImage

【1】创建 Paper 组件继承自 StatefulWidget

因为读取图片是一个异步操作。在读取完毕后,需要重新渲染界面,也就是可变状态。
现在要有一个概念: 画布只承担绘制工作,一切的数据来源由使用者提供
也就是将ui.Image对象作为参数传给在 PaperPainter,画板只专注于绘制操作

---->[p04_canvas/14_image/paper.dart]----
class Paper extends StatefulWidget {
  @override
  _PaperState createState() => _PaperState();
}

class _PaperState extends State<Paper{
  ui.Image _image;

  @override
  void initState() {
    super.initState();
    _loadImage();
  }

  void _loadImage() async {
    _image =
        await loadImageFromAssets('assets/images/wy_300x200.jpg');
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: Colors.white, 
        child: CustomPaint(painter: PaperPainter(_image)));
  }

  // 方法在上面已给出,不再重复书写...
  Future<ui.Image> loadImageFromAssets()... 
}

2. 图片的绘制:drawImage

Canvas#drawImage 方法需要传入 ui.Image 对象、偏移量 Offset画笔 Paint
shouldRepaint 方法决定是否重新调用 paint 方法,这里当新旧 image 不同时允许重绘。

下面图片尺寸 300*200, 直接绘制图片会取图片的原尺寸。
flutter画布绘制图片和文字
---->[p04_canvas/14_image/paper.dart]----
class PaperPainter extends CustomPainter {
  Paint _paint;
  final double strokeWidth = 0.5;
  final Color color = Colors.blue;

  final ui.Image image;

  PaperPainter(this.image) {
    _paint = Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = strokeWidth
      ..color = color;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.height / 2);
    _drawImage(canvas);
  }

  @override
  bool shouldRepaint(PaperPainter oldDelegate) =>
      image != oldDelegate.image;

  void _drawImage(Canvas canvas) {
    if (image != null) {
      canvas.drawImage(
          image, Offset(-image.width / 2, -image.height / 2), _paint);
    }
  }
}

3. 图片域绘制:drawImageRect

drawImageRect中主要是两个矩形域,srcdst
src 表示从资源图片 image 上抠出一块矩形域,所以原点是图片的左上角
dst 表示将抠出的图片填充到画布的哪个矩形域中,所以原点是画布原点

flutter画布绘制图片和文字
---->[p04_canvas/15_image_rect/paper.dart]----
void _drawImageRect(Canvas canvas) {
  if (image != null) {
    canvas.drawImageRect(
        image,
        Rect.fromCenter(
          center: Offset(image.width/2, image.height/2), width: 60, height: 60),
        Rect.fromLTRB(00100100).translate(2000),
        _paint);
        
    canvas.drawImageRect(
        image,
        Rect.fromCenter(
          center: Offset(image.width/2, image.height/2-60), width: 60, height: 60),
        Rect.fromLTRB(00100100).translate(-280-100),
        _paint);
        
    canvas.drawImageRect(
        image,
        Rect.fromCenter(
          center: Offset(image.width/2+60, image.height/2), width: 60, height: 60),
        Rect.fromLTRB(00100100).translate(-28050),
        _paint);
  }
}

4. 图片 .9 域绘制:drawImageNine

drawImageNine 中主要是两个矩形域,centerdst
center 表示从资源图片image上一块可缩放的矩形域,所以原点是图片的左上角
dst 表示将抠出的图片填充到画布的哪个矩形域中,所以原点是画布原点
这样很容易画出气泡的效果,即指定区域进行缩放,其余不动。

flutter画布绘制图片和文字
---->[p04_canvas/16_image_rect/paper.dart]----
void _drawImageNine(Canvas canvas) {
  if (image != null) {
    canvas.drawImageNine(
        image,
        Rect.fromCenter(center: Offset(image.width/2, image.height-6.0),
            width: image.width-20.0, height: 2.0),
        Rect.fromCenter(center: Offset(00,), width:300, height: 120),
        _paint);
        
    canvas.drawImageNine(
        image,
        Rect.fromCenter(center: Offset(image.width/2, image.height-6.0),
            width: image.width-20.0, height: 2.0),
        Rect.fromCenter(center: Offset(00,), width:100, height: 50).translate(2500),
        _paint);
        
    canvas.drawImageNine(
        image,
        Rect.fromCenter(center: Offset(image.width/2, image.height-6.0),
            width: image.width-20.0, height: 2.0),
        Rect.fromCenter(center: Offset(00,), width:80, height: 250).translate(-2500),
        _paint);
  }
}

5.绘制图集:drawAtlas

这个方法有七个参数,用起来比较复杂。主要作用是在画布上绘制一张图片上的很多部分,比如雪碧图 (Sprite) 将需要的图片放在一张图里。另外通过 drawAtlas 绘制的效率要更高。

flutter画布绘制图片和文字
image-20201030113116423

【1】定义每个Sprite类封装图片元素数据

---->[p04_canvas/17_image_drawAtlas/paper.dart]----
class Sprite {
  Rect position; // 雪碧图 中 图片矩形区域
  Offset offset; // 移动偏倚
  int alpha; // 透明度
  double rotation; // 旋转角度
  Sprite({this.offset, this.alpha, this.rotation, this.position});
}

【2】绘制一个图片

绘制时必须传入的参数是图片 ui.Image变换列表List<RSTransform>矩形域列表 List<Rect>
下面通过矩形域 Rect.fromLTWH(0, 325, 257, 166) 可以绘制出大图中的这张图片:

flutter画布绘制图片和文字
image-20201030122623410
class PaperPainter extends CustomPainter {
  Paint _paint;
  final ui.Image image;
  final List<Sprite> allSprites = []; // Sprite 列表
  
  PaperPainter(this.image) {
    _paint = Paint();
  }

  @override
  void paint(Canvas canvas, Size size) {
    if (image == null) {
      return;
    }
    // 添加一个 Sprite
    allSprites.add(Sprite(
        position: Rect.fromLTWH(0325257166),
        offset: Offset(00),
        alpha: 255,
        rotation: 0)); 
    // 通过 allSprites 创建 RSTransform 集合
    final List<RSTransform> transforms = allSprites
        .map((sprite) => RSTransform.fromComponents(
              rotation: sprite.rotation,
              scale: 1.0,
              anchorX: 0,
              anchorY: 0,
              translateX: sprite.offset.dx,
              translateY: sprite.offset.dy,
            ))
        .toList();
  // 通过 allSprites 创建 Rect 集合
    final List<Rect> rects =
        allSprites.map((sprite) => sprite.position).toList();

    canvas.drawAtlas(image, transforms, rects, nullnullnull, _paint);
  }

  @override
  bool shouldRepaint(PaperPainter oldDelegate) => image != oldDelegate.image;
}

【3】图形的变换

我们在定义 Sprite 时,可以将变换的属性放在其中,如平移缩放透明度等。
一般雪碧图制作工具都会给出图片在大图中的坐标位置信息,可以解析添加到 allSprites 中。
除此之外的参数还有颜色 color混合模式 blendMode,据此你可以对图片进行更多操作。

flutter画布绘制图片和文字
image-20201030124040144

5.绘制原始图集:drawRawAtlas

这个方法是drawAtlas的底层实现,其中变换列表、矩形域列表都换为Float32List,颜色数组换为Int32List,在使用方式上是一致的。

---->[p04_canvas/18_image_drawRawAtlas/paper.dart]----
allSprites.add(Sprite(
    position: Rect.fromLTWH(0325257166),
    offset: Offset(00),
    alpha: 255,
    rotation: 0));
    
allSprites.add(Sprite(
    position: Rect.fromLTWH(0325257166),
    offset: Offset(257130),
    alpha: 255,
    rotation: 0));
    
Float32List rectList = Float32List(allSprites.length * 4);
Float32List transformList = Float32List(allSprites.length * 4);

for (int i = 0; i < allSprites.length; i++) {
  final Sprite sprite = allSprites[i];
  rectList[i * 4 + 0] = sprite.position.left;
  rectList[i * 4 + 1] = sprite.position.top;
  rectList[i * 4 + 2] = sprite.position.right;
  rectList[i * 4 + 3] = sprite.position.bottom;
  final RSTransform transform = RSTransform.fromComponents(
    rotation: sprite.rotation,
    scale: 1.0,
    anchorX: sprite.anchor.dx,
    anchorY: sprite.anchor.dy,
    translateX: sprite.offset.dx,
    translateY: sprite.offset.dy,
  );
  transformList[i * 4 + 0] = transform.scos;
  transformList[i * 4 + 1] = transform.ssin;
  transformList[i * 4 + 2] = transform.tx;
  transformList[i * 4 + 3] = transform.ty;
}
canvas.drawRawAtlas(image, transformList, rectList, nullnullnull, _paint);

二、文字绘制:

Flutter 里的文字绘制要明显麻烦很多,但属性多也意味着可定制性高
主要的绘制方式是通过 drawParagraphTextPaint

flutter画布绘制图片和文字

1. drawParagraph绘制文字

通过 ParagraphBuilder 构造基本样式 pushStyle 和添加文字addText
builder.build() 可以创建 Paragraph 对象,之后必须对其排布layout 限制区域。
下面蓝色区域是绘制的辅助, 依次是左对齐居中右对齐

flutter画布绘制图片和文字
---->[p04_canvas/19_text/paper.dart]----
void _drawTextWithParagraph(Canvas canvas,TextAlign textAlign) {
  var builder = ui.ParagraphBuilder(ui.ParagraphStyle(
    textAlign: textAlign,
    fontSize: 40,
    textDirection: TextDirection.ltr,
    maxLines: 1,
  ));
  builder.pushStyle(
      ui.TextStyle(
          color: Colors.black87, textBaseline: ui.TextBaseline.alphabetic),
    );
  builder.addText("Flutter Unit");
  ui.Paragraph paragraph = builder.build();
  paragraph.layout( ui.ParagraphConstraints(width: 300));
  canvas.drawParagraph(paragraph, Offset(00));
  canvas.drawRect(Rect.fromLTRB(0030040 ), 
                  _paint..color = Colors.blue.withAlpha(33));
}

2. TextPainter 绘制文字

TextPainter的绘制基本上就是对drawParagraph的封装,提供了更多的方法,使用起来简洁一些。所以一般来说都是使用 TextPainter 进行文字绘制。绘制先设置 TextPainter,然后执行 layout() 方法进行布局,其中可以传入布局区域的最大和最小宽度。通过 paint 方法进行绘制。

flutter画布绘制图片和文字
image-20201106090723209
---->[p04_canvas/19_text/paper.dart]----
void _drawTextPaint(Canvas canvas) {
  var textPainter = TextPainter(
      text: TextSpan(
          text: 'Flutter Unit',
          style: TextStyle(fontSize: 40,color: Colors.black)),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr);
  textPainter.layout(); // 进行布局
  textPainter.paint(canvas, Offset.zero); // 进行绘制
}

3. TextPainter 获取文字范围

TextPainter 中可以通过 size 属性获取文字所占区域,注意,获取区域必须在执行 layout 方法之后。
一但确定范围后,就容易实现将文字中心绘制在画布原点,这一个效果是非常重要的。

flutter画布绘制图片和文字
image-20201106092322195
---->[p04_canvas/19_text/paper.dart]----
void _drawTextPaintShowSize(Canvas canvas) {
  TextPainter textPainter = TextPainter(
      text: TextSpan(
          text: 'Flutter Unit',
          style: TextStyle(
              fontSize: 40,
              color: Colors.black)),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr);
  textPainter.layout(); // 进行布局
  Size size = textPainter.size; // 尺寸必须在布局后获取
  textPainter.paint(canvas, Offset(-size.width / 2, -size.height / 2));
  
  canvas.drawRect(
      Rect.fromLTRB(00, size.width, size.height)
          .translate(-size.width / 2, -size.height / 2),
      _paint..color = Colors.blue.withAlpha(33));
}

4. 为文字设置画笔样式

比如设置线型的文字,或为文字添加画笔着色器等。可以使用 TextStyle 中的 foreground 属性提供一个画笔。注意:此属性和 TextStyle#color 属性互斥

image-20201106093051567
---->[p04_canvas/19_text/paper.dart]----
void _drawTextPaintWithPaint(Canvas canvas) {
  Paint textPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 1;
  TextPainter textPainter = TextPainter(
      text: TextSpan(
          text: 'Flutter Unit by 张风捷特烈',
          style: TextStyle(
              foreground: textPaint, fontSize: 40)),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr);
  textPainter.layout(maxWidth: 280); // 进行布局
  Size size = textPainter.size; // 尺寸必须在布局后获取
  textPainter.paint(canvas, Offset(-size.width / 2, -size.height / 2));
  
  canvas.drawRect(
      Rect.fromLTRB(00, size.width, size.height)
          .translate(-size.width / 2, -size.height / 2),
      _paint..color = Colors.blue.withAlpha(33));
}

3.为坐标系添加刻度

这里在之前的坐标系上进行优化。对于 step 过小而导致的刻度密集,这里采取简单的处理方案:
step < 30 时,第奇数个刻度被忽略。如下是 step = 25 时的坐标系。

image-20201106094922178
---->[p04_canvas/20_coo_text/paper.dart]----
// 定义成员变量
final TextPainter _textPainter = TextPainter(textDirection: TextDirection.ltr);
// 绘制方法
void _drawText(Canvas canvas, Size size) {
  // y > 0 轴 文字
  canvas.save();
  for (int i = 0; i < size.height / 2 / step; i++) {
    if (step < 30 && i.isOdd || i == 0) {
      canvas.translate(0, step);
      continue;
    } else {
      var str = (i * step).toInt().toString();
      _drawAxisText(canvas, str, color: Colors.green);
    }
    canvas.translate(0, step);
  }
  canvas.restore();
  // x > 0 轴 文字
  canvas.save();
  for (int i = 0; i < size.width / 2 / step; i++) {
    if (i == 0) {
      _drawAxisText(canvas, "O", color: Colors.black, x: null);
      canvas.translate(step, 0);
      continue;
    }
    if (step < 30 && i.isOdd) {
      canvas.translate(step, 0);
      continue;
    } else {
      var str = (i * step).toInt().toString();
      _drawAxisText(canvas, str, color: Colors.green, x: true);
    }
    canvas.translate(step, 0);
  }
  canvas.restore();
  // y < 0 轴 文字
  canvas.save();
  for (int i = 0; i < size.height / 2 / step; i++) {
    if (step < 30 && i.isOdd || i == 0) {
      canvas.translate(0, -step);
      continue;
    } else {
      var str = (-i * step).toInt().toString();
      _drawAxisText(canvas, str, color: Colors.green);
    }
    canvas.translate(0, -step);
  }
  canvas.restore();
  // x < 0 轴 文字
  canvas.save();
  for (int i = 0; i < size.width / 2 / step; i++) {
    if (step < 30 && i.isOdd || i == 0) {
      canvas.translate(-step, 0);
      continue;
    } else {
      var str = (-i * step).toInt().toString();
      _drawAxisText(canvas, str, color: Colors.green, x: true);
    }
    canvas.translate(-step, 0);
  }
  canvas.restore();
}

void _drawAxisText(Canvas canvas, String str,
    {Color color = Colors.black, bool x = false}) {
  TextSpan text = TextSpan(
      text: str,
      style: TextStyle(  fontSize: 11,  color: color ));
  _textPainter.text = text;
  _textPainter.layout(); // 进行布局
  
  Size size = _textPainter.size;
  Offset offset = Offset.zero;
  if (x == null) {
    offset = Offset(88);
  } else if (x) {
    offset = Offset(-size.width / 2, size.height / 2);
  } else {
    offset = Offset(size.height / 2, -size.height / 2 + 2);
  }
  _textPainter.paint(canvas, offset);
}

到此为止,Canvas 的方法基本上都涉及到了,你应该已经知道Canvas 有哪些能力,可以帮我们做什么。下面就将见识一下绘制中最重要,也是最难的一个对象 Path