0. 서문
화살표를 그리는 것은 할말이 없다고 생각하시는 분들이 계실텐데요, 한 줄에 머리 두 개를 더하면 되지 않나요? 사실 화살표를 그리는 것은 상당히 복잡하고 작은 드로잉 기술도 많이 포함되어 있습니다. 화살표 자체는 매우 강 示意功能
하며 일반적으로 표시, 레이블 및 연결에 사용됩니다. 다양한 선 유형과 결합된 다양한 화살표 끝을 의 클래스 다이어그램과 UML
같은 .
핵심 데이터가 左右端点
및 线型
. 이 기사에서는 다양한 스타일을 지원하고 확장하기 쉬운 화살표를 그리는 방법을 살펴봅니다.
1. 화살표 부품 구분
우선 제가 얻고 싶은 것은 화살표 路径
만 그리는 것이 아니라 화살표라는 점을 말씀드리고 싶습니다. 경로로 인해 경로에 따라 자르기, 경로를 따라 이동, 여러 경로 간의 작업 병합 등과 같은 더 많은 작업을 수행할 수 있습니다. 물론 경로가 형성되고 나면 그리기는 당연히 매우 간단합니다. 따라서 드로잉 기술에서 경로는 매우 중요한 주제입니다.
아래와 같이 먼저 세 부분으로 된 경로를 생성하고 그립니다. 두 끝은 일시적으로 원형 경로입니다.
코드는 다음과 같이 구현되며, 테스트에 사용된 시작점은 (40,40)
각각 (200,40)
이고, 원형 경로는 시작점을 중심으로 하고 너비와 높이는 입니다 10
. 요구 사항이 구현되어 있지만 모두 함께 작성되고 코드가 지저분해 보이는 것을 알 수 있습니다. 다양한 스타일의 화살표를 생성할 때 여기에서 코드를 수정하는 것도 매우 번거롭습니다.다음으로 할 일은 화살표의 경로 형성 과정을 추상화하는 것입니다.
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);
复制代码
다음과 같이 추상 클래스 AbstractPath
를 formPath
하고 하위 클래스에서 구현하도록 합니다. 끝점의 경로가 파생 PortPath
및 구현되어 일부 반복되는 논리를 캡슐화할 수 있으며 유지 관리 및 확장에도 도움이 됩니다. 전체 경로의 생성은 ArrowPath
클래스 .
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());
}
}
复制代码
이러한 방식으로 직사각형 영역의 결정과 경로 생성은 특정 클래스에 의해 구현되며, 이는 사용 시 훨씬 더 편리할 것입니다.
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. 경로의 변형에 대해
上面我们的直线其实是矩形路径,这样就会出现一些问题,比如当箭头不是水平线,会出现如下问题:
解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换
。如下代码添加的四行 Matrix4
的操作,就可以通过矩阵变换,让 linePath
以 center
为中心旋转两点间角度。这里注意一下,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
都有一个矩形区域,接下来只要专注于在该区域内绘制箭头即可。比如下面的 p0
、p1
、p2
可以形成一个三角形:
对应代码如下:
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.5
和 0.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
即可。如下红色箭头的两端分别使用 ThreeAnglePortPath
和 CirclePortPath
。
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
实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。