【Flutter 绘制番外】svg 文件与绘制 (中)

5,164 阅读5分钟
前言

上一篇《【Flutter 绘制番外】svg 文件与绘制 (上)》中,我们对 H、V、L 三个 svg 指令做了介绍,并通过正则表达式进行解析,生成 Flutter 绘制中的 Path 路径。
本篇中将会介绍两个指令 CQ ,它们分别代表 三次贝塞尔曲线(cubic)二次贝塞尔曲线(quadratic) 。对这两个指令进行解析后,就可以让掘金的 svg 图标完美显示了:


一、为何要解析 svg ?

可能有人并不能理解,为什么你要把 svg 解析成 Flutter 中的 Path ? 那只能说,你还不了解在绘制中 Path 对象的地位。比如,有了 Path 就可以对绘制进行精细的控制,比如,绘制线框:


其实有了路径之后,就是绘制技能的事了,比如给个渐变色:

外框渐变填充渐变
image-20220319163545893

比如通过 shader 为绘制增加图片进行着色

或通过 maskFilter 添加 滤色,其实这些本质上都是属于绘制技能的范畴,和 svg 本身并没有太大关系。是 Path 对象让这并无关联的两者产生了交集。关于绘制的技能,在 《Flutter 绘制指南 - 妙笔生花》 中有详细介绍。

MaskFilter.blur(BlurStyle.inner, 10)

MaskFilter.blur(BlurStyle.solid, 20)

再比如说,有了路径,就可以通过 computeMetrics 完成如下路径绘制的动画。以前有人问过我这种效果如何实现,其实本质上就是路径的操作而已。但是并不是随便给个字就 Flutter 就能拿到路径的,让设计小姐姐用软件帮你设计对应文字的 svg 路径就行了,就像下面的 稀土掘金 一样:

其实 svg 本身是一个 记录信息 的静态文件,如果能够解析为Flutter 中的 Path 类对象,就可以有更大的应用空间。毕竟在一旦可以在代码中进行逻辑处理,就能产生无限的可能性。这就是为何要解析 svg 的必要性之一;另外还有两个好处:加深对 svg 文件的理解练习正则解析的能力


二、对 svg 解析的封装

上一篇中直接在画板类中对 svg 文件进行解析,这样无论是对于复用,还是维护拓展都是很不友好的。我们可以封装成一个类单独处理解析的逻辑。如下,定义 SVGPathResult 是解析每条路径的结果。包括路径字符串 path ,填充色 fillColor ,边线色 strokeColor 和 边线宽度 strokeWidth
SVGParser 中定义一个 parser 方法,解析 src 字符串,生成 SVGPathResult 列表:

class SVGPathResult {
  final String? path;
  final String? fillColor;
  final String? strokeColor;
  final String? strokeWidth;

  SVGPathResult({
    required this.path,
    this.fillColor,
    this.strokeColor,
    this.strokeWidth,
  });
}

class SVGParser {
  
  List<SVGPathResult?> parser(String src) {
    List<SVGPathResult?> result = [];
    // TODO 解析 svg 文件
    return result;
  }
}

1. svg 文件的解析

其实 svg 文件本身就是 xml 的一个子集,所以整体的结构可以通过 xml 解析器去解析,这里引入了 xml 包:

---->[pubspec.yaml]----
xml: ^5.3.1

对节点的解析也非常简单,XmlDocument 对象就是真个 xml 的文档树;findAllElements 方法可以查询子集某类标签。用该方法可以获取到所有的 path 节点,然后遍历节点,通过 getAttribute 方法获取需要的属性信息。这样就可以从 svg 文件中提取期望的数据。

List<SVGPathResult?> parser(String src) {
  List<SVGPathResult?> result = [];
  final XmlDocument document = XmlDocument.parse(src);
  XmlElement? root = document.getElement('svg');
  if (root == null) return result;
  List<XmlElement> pathNodes = root.findAllElements('path').toList();
  pathNodes.forEach((pathNode) {
    String? pathStr = pathNode.getAttribute('d');
    String? fillColor = pathNode.getAttribute('fill');
    String? strokeColor = pathNode.getAttribute('stroke');
    String? strokeWidth = pathNode.getAttribute('stroke-width');
    result.add(SVGPathResult(
      path: pathStr,
      fillColor: fillColor,
      strokeColor: strokeColor,
      strokeWidth: strokeWidth,
    ));
  });
  return result;
}

2. svg 路径的解析

可以看出 svg 文件的解析通过 xml 解析,并没有好费我们多大的心力。上面解析出的 path 是字符串,接下来就要面临把字符串解析成 Path 路径的问题了。这里我是希望这段逻辑可以单独抽离出来,所以定义了一个 SvgUtils 的类,通过静态方法 convertFromSvgPath 来完成这项工作。
其中解析逻辑在上一篇中也介绍了一些,本文中会拓展 CQ 两个指令,只需要修改该方法内逻辑即可:


要解析 CQ 两个指令,首先要明白它们是干嘛用的。如下所示 C 后面数字个数是 6 的倍数,表示三次贝塞尔曲线,也就是 控制点1控制点2终点 三组坐标。 Q 后面数字个数是 4 的倍数,表示二次贝塞尔曲线,也就是 控制点终点 两组坐标。

我们知道 Flutter 中的 cubicTo 方法是形成三次贝塞尔曲线路径的,其中刚好是 6 个入参,实际就是解析出数字,填进去就行了。

if (op.startsWith("C")) {
  List<String> pos = op.substring(1).trim().split(RegExp(r'[, ]'));
  for (int i = 0; i < pos.length; i += 6) {
    double p0x = num.parse(pos[i]).toDouble();
    double p0y = num.parse(pos[i + 1]).toDouble();
    double p1x = num.parse(pos[i + 2]).toDouble();
    double p1y = num.parse(pos[i + 3]).toDouble();
    double p2x = num.parse(pos[i + 4]).toDouble();
    double p2y = num.parse(pos[i + 5]).toDouble();
    path.cubicTo(p0x, p0y, p1x, p1y, p2x, p2y);
    lastX = p2x;
    lastY = p2y;
  }
}

同理, Flutter 中的 quadraticBezierTo 方法是形成二次贝塞尔曲线路径的,其中有是 4 个入参,也是解析出数字作为入参。这样将解析逻辑封装在 PathConvert#convertFromSvgPath 中,当需要拓展其他指令时,只要在这里修改即可。 svg 文件的解析交由 SVGParser 类处理,这样就能各司其职。

if (op.startsWith("Q")) {
  List<String> pos = op.substring(1).trim().split(RegExp(r'[, ]'));
  for (int i = 0; i < pos.length; i += 4) {
    double p0x = num.parse(pos[i]).toDouble();
    double p0y = num.parse(pos[i + 1]).toDouble();
    double p1x = num.parse(pos[i + 2]).toDouble();
    double p1y = num.parse(pos[i + 3]).toDouble();
    path.quadraticBezierTo(p0x, p0y, p1x, p1y);
    lastX = p1x;
    lastY = p1y;
  }
}

3.画笔的设置

svgpath 节点下有 fill 属性表示填充, storke 表示线条。 这些是绘制中画笔Paint 的属性,所有需要根据这些属性来设置画笔:

如下,通过 extensionSVGPathResult 类进行拓展,给出 setPaint 方法。根据自身属性为传入的画笔设置属性。

extension SetPaintBySVGPath on SVGPathResult{
  void setPaint(Paint paint){
    if (this.strokeColor != null) {
      paint..style = PaintingStyle.stroke;
      Color resultColor = Color(
          int.parse(this.strokeColor!.substring(1), radix: 16) + 0xFF000000);
      paint..color = resultColor;
    }
    if (this.strokeWidth != null) {
      paint..strokeWidth = num.parse(this.strokeWidth!).toDouble();
    }
    if (this.fillColor != null) {
      paint..style = PaintingStyle.fill;
      Color resultColor = Color(
          int.parse(this.fillColor!.substring(1), radix: 16) + 0xFF000000);
      paint..color = resultColor;
    }
  }
}

可能有人会问,为什么不直接在 SVGPathResult 中写这个方法,而是进行拓展呢?这里是想让 SVGPathResult纯粹 一些,只承担收录解析路径信息的职能,基于其上的功能可以让使用者自己拓展。
另外Paint 本身是 Flutter 中的类,需要运行在设备上起来才能调试,这样并不方便。不引入 Paint ,就可以让 SVGParser 脱离 Flutter 而存在,其中所用的都是 dart 语言本身的类,可以脱离 Flutter 运行。


三、解析结果在 Flutter 中的绘制

经过上面的解析和对 Path 以及 Paint 的处理,剩下的绘制工作就非常简单了。如下代码,解析完后,遍历 SVGPathResult 列表,生成路径,绘制即可。代码见【extra_02_svg/02】

---->[paint]----
List<SVGPathResult?> parserResults = svgParser.parser(src);
parserResults.forEach((SVGPathResult? result) {
  if (result == null) return;
  if (result.path != null) {
    Path path = SvgUtils.convertFromSvgPath(result.path!);
    result.setPaint(mainPaint);
    canvas.drawPath(path, mainPaint);
  }
});

对显示进行效果处理,本质上是通过读画笔的 maskFiltershader 进行设置。比如下面通过 shader ,使用一张图片进行着色,代码见 【extra_02_svg/03】

Matrix4 matrix4 = Matrix4.diagonal3Values(0.1, 0.1, 1)
    .multiplied(Matrix4.translationValues(70, 10, 0));

mainPaint.shader = ImageShader(
  img,
  TileMode.repeated,
  TileMode.repeated,
  matrix4.storage,
);

另外路径动画就是结合动画控制器和 computeMetrics 对路径进行测量,【extra_02_svg/05】

parserResults.forEach((SVGPathResult? result) {
  if (result == null) return;
  if (result.path != null) {
    Path path = SvgUtils.convertFromSvgPath(result.path!);
    result.setPaint(mainPaint);
    PathMetrics pms = path.computeMetrics();
    mainPaint.style = PaintingStyle.stroke;
    pms.forEach((pm) {
      canvas.drawPath(pm.extractPath(0, pm.length * progress.value), mainPaint);
    });
  }
});

掘金的 svg 只用到了这几个命令,看似比较完美,但是 svg 的命令可不止于此。还有其他的指令需要解析,比如 A、Q、T 等,另外还有与大写字母相对于的小写字母表示相对路径,这些都需要对解析逻辑进行拓展。那本篇就到这里,下篇再见,谢谢观看~