Flutter Drawing Exploration | Dessinons des flèches ensemble

0. Préface

Certaines personnes peuvent penser qu'il n'y a rien à dire sur le dessin de flèches, n'ajoutez-vous pas simplement deux têtes à une ligne? En fait, le dessin des flèches est assez compliqué et contient également de nombreuses petites compétences en dessin. La flèche elle-même est très forte 示意功能et est généralement utilisée pour indiquer, étiqueter et connecter. Diverses extrémités de flèche, associées à différents types de lignes, peuvent être combinées dans une syntaxe de connexion fixe, telle UMLque le diagramme de classes dans .


Une flèche dont les données de base sont les coordonnées de deux points, consistant en 左右端点et 线型. Cet article explorera comment dessiner une flèche qui prend en charge différents styles et est facile à développer.


1. Division des pièces de flèche

Tout d'abord, je tiens à dire que ce que je veux obtenir, ce sont des flèches 路径, pas seulement dessiner des flèches. En raison du chemin, vous pouvez faire plus de choses, comme couper en fonction du chemin, vous déplacer le long du chemin, fusionner des opérations entre plusieurs chemins, etc. Bien sûr, une fois le chemin formé, le dessin est naturellement très simple. Ainsi, dans les compétences en dessin, les chemins sont un sujet très important.
Comme indiqué ci-dessous, nous générons d'abord un chemin en trois parties et le dessinons. Les deux extrémités sont temporairement des chemins circulaires :

Le code est implémenté comme suit, les points de départ utilisés dans le test sont (40,40)et (200,40), la trajectoire circulaire est centrée sur le point de départ, et la largeur et la hauteur sont 10. On peut voir que bien que les exigences soient implémentées, elles sont toutes écrites ensemble et le code semble désordonné. Lorsqu'il s'agit de générer différents styles de flèches, il est également très difficile de modifier le code ici.La prochaine chose à faire est d'abstraire le processus de formation du chemin des flèches.

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);
复制代码

Comme suit, définissez une classe abstraite AbstractPathpour formPathl'abstraire et laissez-la être implémentée par des sous-classes. Le chemin du point de terminaison est dérivé PortPathet implémenté, ce qui peut encapsuler une logique répétée, et est également propice à la maintenance et à l'expansion. La génération du cheminement global est du ressort de la ArrowPathclasse :

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());
  }
}
复制代码

De cette manière, la détermination du domaine rectangulaire et la génération du chemin sont implémentées par des classes spécifiques, ce qui sera beaucoup plus pratique à l'usage :

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. À propos de la transformation du chemin

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

解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换 。如下代码添加的四行 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 实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。

Je suppose que tu aimes

Origine juejin.im/post/7120010916602576926
conseillé
Classement