Flutter Drawing Exploration | Let's draw arrows together

0. Preface

Some people may think that there is nothing to say about drawing arrows, don't you just add two heads to one line? In fact, the drawing of arrows is quite complicated, and it also contains many small drawing skills. The arrow itself is very strong 示意功能and is usually used to indicate, label, and connect. Various arrow ends, coupled with different line types, can be combined into some fixed connection syntax, such UMLas the class diagram in .


An arrow whose core data are the coordinates of two points, consisting of 左右端点and 线型. This article will explore how to draw an arrow that supports various styles and is easy to expand.


1. Division of arrow parts

First of all, I want to say that what I want to get is arrows 路径, not just drawing arrows. Because of the path, you can do more things, such as cutting according to the path, moving along the path, merging operations between multiple paths, etc. Of course, after the path is formed, drawing is naturally very simple. So in drawing skills, paths are a very important topic.
As shown below, we first generate a three-part path and draw it. The two ends are temporarily circular paths:

The code is implemented as follows, the starting points used in the test are (40,40)and (200,40), the circular path is centered on the starting point, and the width and height are 10. It can be seen that although the requirements are implemented, they are all written together, and the code looks messy. When it comes to generating various styles of arrows, it is also very troublesome to modify the code here. The next thing to do is to abstract the path formation process of the arrows.

final Paint arrowPainter = Paint();

Offset p0 = Offset(40, 40);
Offset p1 = Offset(200, 40);
double width = 10;
double height = 10;

Rect startZone = Rect.fromCenter(center: p0, width: width, height: height);
Path startPath = Path()..addOval(startZone);
Rect endZone = Rect.fromCenter(center: p1, width: width, height: height);
Path endPath = Path()..addOval(endZone);

Path linePath = Path()..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy);

arrowPainter
  ..style = PaintingStyle.stroke..strokeWidth = 1
  ..color = Colors.red;

canvas.drawPath(startPath, arrowPainter);
canvas.drawPath(endPath, arrowPainter);
canvas.drawPath(linePath, arrowPainter);
复制代码

As follows, define an abstract class AbstractPathto formPathabstract it out and let it be implemented by subclasses. The path of the endpoint is derived PortPathand implemented, which can encapsulate some repeated logic, and is also conducive to maintenance and expansion. The generation of the overall path is the responsibility of the ArrowPathclass :

abstract class AbstractPath{
  Path formPath();
}

class PortPath extends AbstractPath{
  final Offset position;
  final Size size;

  PortPath(this.position, this.size);

  @override
  Path formPath() {
    Path path = Path();
    Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
    path.addOval(zone);
    return path;
  }
}

class ArrowPath extends AbstractPath{
  final PortPath head;
  final PortPath tail;

  ArrowPath({required this.head,required this.tail});

  @override
  Path formPath() {
    Offset line = (tail.position - head.position);
    Offset center = head.position+line/2;
    double length = line.distance;
    Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
    Path linePath = Path()..addRect(lineZone);
    Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
    return Path.combine(PathOperation.union, temp, tail.formPath());
  }
}
复制代码

In this way, the determination of the rectangular domain and the generation of the path are implemented by specific classes, which will be much more convenient in use:

double width =10;
double height =10;
Size portSize = Size(width, height);
ArrowPath arrow = ArrowPath(
  head: PortPath(p0, portSize),
  tail: PortPath(p1, portSize),
);
canvas.drawPath(arrow.formPath(), arrowPainter);
复制代码


2. About the transformation of the path

上面我们的直线其实是矩形路径,这样就会出现一些问题,比如当箭头不是水平线,会出现如下问题:

解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换 。如下代码添加的四行 Matrix4 的操作,就可以通过矩阵变换,让 linePathcenter 为中心旋转两点间角度。这里注意一下,tag1 处的平移是为了将变换中心变为 center、而tag2 处的反向平移是为了抵消 tag1 平移的影响。这样在两者之间的变换,就是以 center 为中心的变换:

class ArrowPath extends AbstractPath{
  final PortPath head;
  final PortPath tail;

  ArrowPath({required this.head,required this.tail});

  @override
  Path formPath() {
    Offset line = (tail.position - head.position);
    Offset center = head.position+line/2;
    double length = line.distance;
    Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
    Path linePath = Path()..addRect(lineZone);

    // 通过矩阵变换,让 linePath 以 center 为中心旋转 两点间角度 
    Matrix4 lineM4 = Matrix4.translationValues(center.dx, center.dy, 0); // tag1
    lineM4.multiply(Matrix4.rotationZ(line.direction));
    lineM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0)); // tag2
    linePath = linePath.transform(lineM4.storage);

    Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
    return Path.combine(PathOperation.union, temp, tail.formPath());
  }
}
复制代码

这样就一切正常了,可能有人会疑惑,为什么不直接用两点形成路径呢?这样就不需要旋转了:

前面说了,这里希望获得的是一个 箭头路径 ,使用线型模式就可以看处用矩形的妙处。如果单纯用路径的移动来处理,需要计算点位,比较复杂。而用矩形加旋转,就方便很多:


3.尺寸的矫正

可以看出,目前是以起止点为圆心的矩形区域,但实际我们需要让箭头的两端顶点在两点上。有两种解决方案:其一,在 PortPath 生成路径时,对矩形区域中心进行校正;其二,在合成路径前通过偏移对首位断点进行校正。

我更倾向于后者,因为我希望 PortPath 只负责断点路径的生成,不需要管其他的事。另外 PortPath 本身也不知道端点是起点还是终点,因为起点需要沿线的方向偏移,终点需要沿反方向偏移。处理后效果如下:

---->[ArrowPath#formPath]----
Path headPath = head.formPath();
Matrix4 headM4 = Matrix4.translationValues(head.size.width/2, 0, 0);
headPath = headPath.transform(headM4.storage);

Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-head.size.width/2, 0, 0);
tailPath = tailPath.transform(tailM4.storage);
复制代码

虽然表面上看起来和顶点对齐了,但换个不水平的线就会看出端倪。我们需要 沿线的方向 进行平移,也就是说,要保证该直线过矩形区域圆心:

如下所示,我们在对断点进行平移时,需要根据线的角度来计算偏移量:

 Path headPath = head.formPath();
 double fixDx = head.size.width/2*cos(line.direction);
 double fixDy = head.size.height/2*sin(line.direction);
 
 Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
 headPath = headPath.transform(headM4.storage);
 Path tailPath = tail.formPath();
 Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
 tailPath = tailPath.transform(tailM4.storage);
复制代码

4.箭头的绘制

每个 PortPath 都有一个矩形区域,接下来只要专注于在该区域内绘制箭头即可。比如下面的 p0p1p2 可以形成一个三角形:

对应代码如下:

class PortPath extends AbstractPath{
  final Offset position;
  final Size size;

  PortPath(this.position, this.size);

  @override
  Path formPath() {
    Path path = Path();
    Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
    Offset p0 = zone.centerLeft;
    Offset p1 = zone.bottomRight;
    Offset p2 = zone.topRight;
    path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
    return path;
  }
}
复制代码

由于在 PortPath 中无法感知到子级是头还是尾,所以下面可以看出两个箭头都是向左的。处理方式也很简单,只要转转 180° 就行了。


另外,这样虽然看起来挺好,但也有和上面类似的问题,当改变坐标时,就会出现不和谐的情景。解决方案和前面一样,为断点的箭头根据线的倾角添加旋转变换即可。


如下进行旋转,即可得到期望的箭头,tag3 处可以顺便旋转 180° 把尾点调正。这样任意指定两点的坐标,就可以得到一个箭头。

Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
center = head.position;
headM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
headM4.multiply(Matrix4.rotationZ(line.direction));
headM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
headPath = headPath.transform(headM4.storage);

Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
center = tail.position;
tailM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
tailM4.multiply(Matrix4.rotationZ(line.direction-pi)); // tag3
tailM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
tailPath = tailPath.transform(tailM4.storage);
复制代码

5.箭头的拓展

从上面可以看出,这个箭头断点的拓展能力是很强的,只要在矩形区域内形成相应的路径即可。比如下面带两个尖角的箭头形式,路径生成代码如下:

class PortPath extends AbstractPath{
  final Offset position;
  final Size size;

  PortPath(this.position, this.size);

  @override
  Path formPath() {
    Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
    return pathBuilder(zone);
  }

  Path pathBuilder(Rect zone){
    Path path = Path();
    Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
    final double rate = 0.8;
    Offset p0 = zone.centerLeft;
    Offset p1 = zone.bottomRight;
    Offset p2 = zone.topRight;
    Offset p3 = p0.translate(rate*zone.width, 0);
    path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)
      ..lineTo(p3.dx, p3.dy)..lineTo(p2.dx, p2.dy)..close();
    return path;
  }
}
复制代码

这样如下所示,只要更改 pathBuilder 中的路径构建逻辑,就可以得到不同的箭头样式。而且你只需要在矩形区域创建正着的路径即可,箭头跟随直线的旋转已经被封装在了 ArrowPath 中。这就是 屏蔽细节 ,简化使用流程。不然创建路径时还有进行角度偏转计算,岂不麻烦死了。


到这里,多样式的箭头设置方案应该就呼之欲出了。就像是 Flutter 动画中的各种 Curve 一样,通过抽象进行衍生,实现不同类型的数值转变。这里我们也可以对路径构建的行为进行抽象,来衍生出各种路径类。这样的好处在于:在实现类中,可以定义额外的参数,对绘制的细节进行控制。
如下,抽象出 PortPathBuilder ,通过 fromPathByRect 方法,根据矩形区域生成路径。在 PortPath 中就可以依赖 抽象 来完成任务:

abstract class PortPathBuilder{
  const PortPathBuilder();
  Path fromPathByRect(Rect zone);
}

class PortPath extends AbstractPath {
  final Offset position;
  final Size size;
  PortPathBuilder portPath;

  PortPath(
    this.position,
    this.size, {
    this.portPath = const CustomPortPath(),
  });

  @override
  Path formPath() {
    Rect zone = Rect.fromCenter(
        center: position, width: size.width, height: size.height);
    return portPath.fromPathByRect(zone);
  }
}
复制代码

在使用时,可以通过指定 PortPathBuilder 的实现类,来配置不同的端点样式,比如实现一开始那个常规的 CustomPortPath :

class CustomPortPath extends PortPathBuilder{
  const CustomPortPath();

  @override
  Path fromPathByRect(Rect zone) {
    Path path = Path();
    Offset p0 = zone.centerLeft;
    Offset p1 = zone.bottomRight;
    Offset p2 = zone.topRight;
    path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
    return path;
  }
}
复制代码

以及三个箭头的 ThreeAnglePortPath ,我们可以将 rate 提取出来,作为构造入参,这样就可以让箭头拥有更多的特性,比如下面是 0.50.8 的对比:

class ThreeAnglePortPath extends PortPathBuilder{
  final double rate;

  ThreeAnglePortPath({this.rate = 0.8});

  @override
  Path fromPathByRect(Rect zone) {
    Path path = Path();
    Offset p0 = zone.centerLeft;
    Offset p1 = zone.bottomRight;
    Offset p2 = zone.topRight;
    Offset p3 = p0.translate(rate * zone.width, 0);
    path
      ..moveTo(p0.dx, p0.dy)
      ..lineTo(p1.dx, p1.dy)
      ..lineTo(p3.dx, p3.dy)
      ..lineTo(p2.dx, p2.dy)
      ..close();
    return path;
  }
}
复制代码

想要实现箭头不同的端点类型,只有在构造 PortPath 时,指定对应的 portPath 即可。如下红色箭头的两端分别使用 ThreeAnglePortPathCirclePortPath

ArrowPath arrow = ArrowPath(
  head: PortPath(
    p0.translate(40, 0),
    const Size(10, 10),
    portPath: const ThreeAnglePortPath(rate: 0.8),
  ),
  tail: PortPath(
    p1.translate(40, 0),
    const Size(8, 8),
    portPath: const CirclePortPath(),
  ),
);
复制代码

这样一个使用者可以自由拓展的箭头绘制小体系就已经能够完美运转了。大家可以基于此体会一下其中 抽象 的意义,以及 多态 的体现。本篇中有很多旋转变换的绘制小技巧,下一篇,我们来一起绘制各种各样的 PortPathBuilder 实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。

Guess you like

Origin juejin.im/post/7120010916602576926