上一篇 《Flutter 绘制探索 | 来一起画箭头吧》 ,实现了一个可以自由拓展的箭头绘制小体系。线和箭头的旋转已经封装好了,只需要在矩形端点矩形域中提供路径即可。本文我们就来对端点的箭头路径进行拓展,丰富箭头的样式,同时也更方便使用者调用。
毕竟用别人现成的要比自己绘制简单地多,也不是所有人都有绘制的能力。这个箭头小系列就是为了打造一个小巧、便捷的箭头绘制库。所以丰富箭头样式是其中主要的一环。
draw.io
是我最喜欢的绘制软件,没有之一,本文就其中的一些常用箭头端点样式进行实现。通过仿写,可以对其中的箭头进行一些额外的参数配置,来满足更多的配置需求。这就是代码由自己掌控的好处,想实现什么可以自己动手,丰衣足食。
1. 箭头绘制环境
打个比方,我要造火箭的螺丝,并没有必要在火箭生产的现场去制作。在车间中根据图纸,按照尺寸制作就行了,造完后拿过去拧上就行了。这就是对复杂场景进行分离,将相对独立的生产对象独立出来,这样能够简化场景,更专注于做一件事。就像本文,我只想专注做一件事,就是如何在一块矩形区域内,来创建各种各样的箭头路径。
为了让我们对箭头的生产有那么一点 设计感
,这里画个如下的辅助路径,对矩形区域进行示意。正所谓,磨刀不误砍柴工,通过这个 4*4
的圈格,可以让设计更直观。这就像是一个坐标,一个参考线,能给我们一些 安全感
。
这个背景绘制的代码如下所示,其实就是一些最基本的路径操作而已。对看过 《Flutter 绘制指南 - 妙笔生花》的朋友来说,这些都是小菜一碟:
void main() {
runApp(const ColoredBox(color: Colors.white, child: Painter()));
}
class Painter extends StatelessWidget {
const Painter({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: PortPathPainter(),
);
}
}
class PortPathPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
Size zoneSize = const Size(150, 150);
Rect zone = Rect.fromCenter(center: Offset.zero,
width: zoneSize.width,
height: zoneSize.height);
drawHelp(canvas, zone);
}
final Paint _helpPaint = Paint()
..style = PaintingStyle.stroke
..color = const Color(0xffE0DEEC);
void drawHelp(Canvas canvas, Rect zone) {
Path path = Path();
final double width = zone.width;
final double height = zone.height;
Rect partZone = Rect.fromCenter(center: Offset.zero,
width: width * 0.5, height: height * 0.5);
path..moveTo(-width / 2, -height / 2)..relativeLineTo(width, height);
path..moveTo(-width / 2, height / 2)..relativeLineTo(width, -height);
path..moveTo(-width / 2, height / 2)..relativeLineTo(width, -height);
path..moveTo(0, -height / 2)..relativeLineTo(0, height);
path..moveTo(-width / 4, -height / 2)..relativeLineTo(0, height);
path..moveTo(width / 4, -height / 2)..relativeLineTo(0, height);
path..moveTo(-width / 2, 0)..relativeLineTo(width, 0);
path..moveTo(-width / 2, height / 4)..relativeLineTo(width, 0);
path..moveTo(-width / 2, -height / 4)..relativeLineTo(width, 0);
path.addRect(zone);
path.addOval(partZone);
path.addOval(zone);
canvas.drawPath(path, _helpPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}复制代码
现在把上一篇的 ThreeAnglePortPath
放在背景中,如下所示,是不是有内味了。其中 rate
参数用于控制中间右侧点的偏移分率,该值越小,上下两个角越尖。
Path result = const ThreeAnglePortPath(rate: 0.75).fromPathByRect(zone);
canvas.drawPath(result, _arrowPaint);复制代码
后面,每种箭头模式,会给出三个图示:辅助线内填充图、辅助线内校稿图和产品图。这样是不是让平平无奇的箭头绘制增加了一丢丢的逼格。
现在看一下上一篇中实现的 CustomPortPath
和 CirclePortPath
两种箭头端点的效果。这样就能很清晰地看出端点路径在矩形区域内的具体表现:
2. 实心三角类型
draw.io
中,有如下五种实心三角相关的箭头,我们已经实现了两个。在实现其他的类型之前,我们需要思考一个问题。在端点的设计中,是否将绘制区域规范为正方形。这个问题会影响对高度较窄箭头的实现方式。
区域尺寸是由使用者传入的,如下使用红框和蓝框,在对路径生成的方式是不同的。这里我更倾向于使用正方形区域,这样更容易进行统一绘制的标准。端点不再传入 Size
尺寸,而是正方形的边长,这和 Icon
的尺寸是类似的。我们在设计中,将区域默认是 正方形
,可以避免很多不必要的尺寸问题,在显示上也没什么区别。
如下,是高度较窄的箭头绘制示意,只需要在形成路径时对右侧上方两点进行竖直平移即可。这样定义了一个 lowRate
参数用于配置偏移的比率。如下 0.15
表示右上角向下平移了 矩形高*0.15
的距离。
class ThreeAngleLowPortPath extends PortPathBuilder{
final double rate;
final double lowRate;
const ThreeAngleLowPortPath({this.rate = 0.75,this.lowRate=0.8});
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight.translate(0, -zone.height*lowRate);
Offset p2 = zone.topRight.translate(0, zone.height*lowRate);
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;
}
}复制代码
同理,下面的第四个可以由第三个,右侧顶点位移进行实现。但仔细观察和思考可以看出,第一个当 rate =1
时,就是第三个。第二个,当 lowRate = 0
就是第一个。也就是说这四种类型的箭头都可以通过 ThreeAngleLowPortPath
的不同配置来获得。
所以并没有用分成四个类来单独处理,那么问题来了,只给一个 ThreeAngleLowPortPath
,在使用时语义并不怎么明确。使用者可能不知道怎么设置是哪种类型。dart
在构造方法中支持 命名构造
,最常见的就是在 ListView
构造时,使用不同的构造,可以以不同的方式构造列表,这样语义就会很明确。这里可以进行借鉴:
class TrianglePortPath extends PortPathBuilder {
final double rate;
final double lowRate;
const TrianglePortPath({this.rate = 0.75, this.lowRate = 0.15});
const TrianglePortPath.custom()
: rate = 1,
lowRate = 0;
const TrianglePortPath.customLow({this.lowRate=0.15})
: rate = 1;
const TrianglePortPath.threeAngle({this.rate = 0.75})
: lowRate = 0;
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight.translate(0, -zone.height * lowRate);
Offset p2 = zone.topRight.translate(0, zone.height * lowRate);
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;
}
}复制代码
比如创建三个尖角的样式,可以使用:
const TrianglePortPath.threeAngle(rate: 0.75)复制代码
创建矮一些的三角形,可以使用:
const TrianglePortPath.customLow()复制代码
这样即避免了类的数量随意增加,又可以有较好的语义,这就是 dart
命名构造的优势。我们在日常开发中,也可以尝试在适当的时候进行借鉴。
下面我们来看半个三角的样式,睁大眼睛仔细分析可以看到:右侧三角的左侧起点并不在矩形区域的水平中轴线上,它是与线的下部对齐的。使用半个三角的样式,我们需要知道线的宽度。
实现效果如下,只要将左侧和右下角的顶点,向下移动 半线宽
即可。通过辅助线就可以看的非常清楚:
class HalfTrianglePortPath extends PortPathBuilder {
final double lineWidth;
const HalfTrianglePortPath({required this.lineWidth});
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft.translate(0, lineWidth/2);
Offset p1 = zone.centerRight.translate(0, lineWidth/2);
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..close();
return path;
}
}复制代码
加上线后,就是如下的效果,断点下方下移了半线宽,才可以保证和线的底部对齐:
如果不进行处理,就会发生如下很不和谐的情况:
3. 线型箭头
下面我们来看这一组,其中箭头粗细和线一致。这看着比较简单,但想要获取对应的路径,还是需要一些处理技巧的。
实现的效果图如下,代码是通过对路径移动实现的。其中比较困难的是:对线宽长度垂线数据的计算。
也就是在这两处路径相对移动时的数据,需要用到一些三角函数的处理:
如下,是对 A -> B -> P
移动中相关位移尺寸的分析图。另外上方那一块和下面是镜像的,下面处理好了,上面就非常简单,可以结合下方的代码体会一下:
class TriangleLinePortPath extends PortPathBuilder {
final double lineWidth;
const TriangleLinePortPath({required this.lineWidth});
@override
String get debugLabel => "TriangleLinePortPath:[lineWidth:$lineWidth]";
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
double radBR = (p1-p0).direction;
double c = zone.height/2-cos(radBR)*lineWidth-lineWidth/2;
path..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..relativeLineTo(sin(radBR)*lineWidth, -cos(radBR)*lineWidth)
..relativeLineTo(-c/(tan(radBR)), -c)
..relativeLineTo((c)/(tan(radBR)), 0)
..relativeLineTo(0, -lineWidth)
..relativeLineTo(-(c)/(tan(radBR)), 0)
..relativeLineTo(c/(tan(radBR)), -c)
..lineTo(p2.dx, p2.dy)..close();
return path;
}
}复制代码
想要窄一些的箭头,同样也可以通过一个 lowRate
参数,对右侧上下两点进行偏移:
加线效果如下:
最后,半线和上面处理逻辑也没有太大的差异。不过,和半三角一样,要注意底部下移半线宽,
class HalfTriangleLinePortPath extends PortPathBuilder {
final double lineWidth;
const HalfTriangleLinePortPath({required this.lineWidth});
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft.translate(0, lineWidth/2);
Offset p2 = zone.topRight;
double radBR = (p2-p0).direction;
double c = zone.height/2-cos(radBR)*lineWidth-lineWidth/2;
path..moveTo(p0.dx, p0.dy)
..lineTo(p2.dx, p2.dy)
..relativeLineTo(-sin(radBR)*lineWidth, cos(radBR)*lineWidth)
..relativeLineTo(c/(tan(radBR)), c)
..relativeLineTo(-c/(tan(radBR)), 0)
..relativeLineTo(0, lineWidth)
..close();
return path;
}
}复制代码
加线效果如下:
4. 几何体填充
接下来看一组实心的几何体,这相对而言是比较简单的,不涉及复杂的计算。
其中圆形我们在上一篇已经实现过了:
如下是菱形的端点效果,实现非常简单,连接四边中点形成路径即可:
class RhombusPortPath extends PortPathBuilder {
const RhombusPortPath();
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomCenter;
Offset p2 = zone.centerRight;
Offset p3 = zone.topCenter;
path..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..close();
return path;
}
}复制代码
对高度窄一些的菱形,同样可以通过 lowRate
对上下点进行竖直平移,效果如下:
class RhombusPortPath extends PortPathBuilder {
final double lowRate;
const RhombusPortPath({this.lowRate=0});
@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomCenter.translate(0, -zone.height*lowRate);
Offset p2 = zone.centerRight;
Offset p3 = zone.topCenter.translate(0, zone.height*lowRate);
path..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..close();
return path;
}
}复制代码
5. 空心类型
如下所示,填充的箭头都有与之对应的空心类型,其特点是外框和线宽一致。对空心路径的实现,我是遇到了一些小挫折的。因为我并不想对空心图形一个个实现,而是希望寻找到一个 通法
来处理,毕竟外框的路径是之前实现过的,再一一计算内框进行合并,感觉比较复杂,也会导致类的增多。
这里有两个变化的维度,有点像 桥接模式
需要解决的问题,不过这里只有线型和填充两种模式,并不需要进行其他的拓展,所以 桥接模式
有点大材小用。我们可以这个 装饰者模式
,通过包裹一层,来达到增加特定功能的目的。
解决空心类型的方案是 缩放
+ 裁剪
。下图是对基本三角的分析,核心就是基于线宽,计算出缩放比例。这是一个非常精细的计算过程,主要是确定内层路径端点偏移量 offsetX
。将缩放的变换中心移动到如下红点处,进行缩放变换。最后偏移 offsetX
即可:
class StokeHandler extends PortPathBuilder {
final PortPathBuilder child;
final double lineWidth;
StokeHandler({required this.child, required this.lineWidth});
@override
Path fromPathByRect(Rect zone) {
Path outPath = child.fromPathByRect(zone);
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
double rad = (p1-p0).direction;
double offsetX = lineWidth/sin(rad);
Matrix4 m4 = Matrix4.identity();
double centerX = -zone.size.width/2+offsetX;
double rate = (zone.width-offsetX-lineWidth)/zone.size.width;
m4.multiply(Matrix4.translationValues(centerX, 0, 0));
m4.multiply(Matrix4.diagonal3Values(rate, rate, 1));
m4.multiply(Matrix4.translationValues(-centerX, 0, 0));
m4.multiply(Matrix4.translationValues(offsetX, 0, 0));
Path innerPath = outPath.transform(m4.storage);
return Path.combine(PathOperation.difference, outPath, innerPath);
}
}复制代码
这样通过 StokeHandler
包裹 TrianglePortPath
对象,就可以实现其空心化。这是一种 可插拔
的模式,更具有灵活性,可以减少一个维度而引起的类暴涨。
PortPathBuilder pathBuilder = const TrianglePortPath.custom();
pathBuilder = StokeHandler(child: pathBuilder,lineWidth: 15);复制代码
理想很丰满,现实很骨感。当使用测试三尖角的模式,发现出问题了,理论上应该是缩放比例计算有误。
仔细分析一下这个图形,可以发现对于不同的箭头样式 shapeWidth
是不同的,这是导致缩放比例错误的根源。但 StokeHandler
在设计之初,只在意路径的产生,所以并无法感知到 PortPathBuilder
实现类的内部数据。
有一种笨方法,根据 child
的运行时类型,进行强转,从而获取内部数据,进行额外处理,如下 tag
处所示。这虽然不太优雅,但确实实现了需求。
double shapeWidth = zone.width;
double offsetEnd = lineWidth;
if(child is TrianglePortPath){ // tag
shapeWidth = zone.width-(1-(child as TrianglePortPath).rate) * zone.width;
}
double centerX = -zone.size.width/2+offsetX;
double rate = (shapeWidth-offsetX-offsetEnd)/(shapeWidth);复制代码
6. 编程心法: 依赖于抽象而非具体实现
这是 依赖倒转原则
的内容,如果只是单纯泛泛而谈,可能很难说明问题。现在刚好这个案例触及到了该原则,可以顺便展开来说一下。首先用人话来说明一下,什么叫 依赖
。比如说,我依赖于电脑编程,说明一个对象(A)在完成功能时,需要借助另一个对象 (B) 的能力,这就是 A 类
依赖于 B 类
。那什么是 抽象
呢,比如 电脑
本身就是一个抽象的概念,它指代的是一类具有某些功能的事物,而不是指哪一类特定的电脑。比如这里的 StokeHandler
依赖于 PortPathBuilder
,是因为需要使用它来创建路径。
依赖于抽象,有一个弊端。因为依赖抽象的这个行为本身,就表明我们主动放弃了对实现类中独有数据的访问。比如上面在 StokeHandler
中除了获取路径外,需要使用实现类中的数据,这就说明这里的抽象没有做好,有些需要的功能并没有抽象出来。一开始未考虑周全也是很正常的事,下面我们来探讨一下,空心路径还需要依赖什么?
如下是对菱形的分析,从中可以看出 rad
的角度、offsetEnd
的长度和上面的情况都不同。这里就很考验我们的对问题的理解能力和抽象能力,也就是摒弃一些细枝末节,一针见血地提供最为必要的功能。比如这里 rad
角度是为了计算 offsetX
,offsetEnd
是为了计算缩放比例,这两个都不是问题的核心。
最核心的配置信息是 偏移距离: offsetX
和 缩放比例:rate
。如下,定义一个 StokePathMixin
接口来对这两个数据的获取进行抽象。
mixin StokePathMixin on PortPathBuilder{
StokeConfig calculateConfig(Rect zone,double lineWidth);
}
class StokeConfig{
final double offsetX;
final double rate;
StokeConfig({required this.offsetX,required this.rate});
}复制代码
这样在 StokeHandler
中依赖于 StokePathMixin
,就可以使用 calculateConfig
获取 StokeConfig
。这就是依赖于抽象的好处,这里运行时 StokeConfig
是什么,fromPathByRect
中是不关心的,我只知道该对象可以通过 calculateConfig
获取 StokeConfig
,来保证后续的平移缩放任务能够完成。
class StokeHandler extends PortPathBuilder {
final StokePathMixin child;
final double lineWidth;
StokeHandler({required this.child, required this.lineWidth});
@override
Path fromPathByRect(Rect zone) {
Path outPath = child.fromPathByRect(zone);
Matrix4 m4 = Matrix4.identity();
StokeConfig config = child.calculateConfig(zone, lineWidth);
double centerX = -zone.size.width/2+config.offsetX;
double rate = config.rate;
m4.multiply(Matrix4.translationValues(centerX, 0, 0));
m4.multiply(Matrix4.diagonal3Values(rate, rate, 1));
m4.multiply(Matrix4.translationValues(-centerX, 0, 0));
m4.multiply(Matrix4.translationValues(config.offsetX, 0, 0));
Path innerPath = outPath.transform(m4.storage);
return Path.combine(PathOperation.difference, outPath, innerPath);
}
}复制代码
比如对于菱形路径而言,可以混入 StokePathMixin
,表示它支持空心化。然后自己实现 calculateConfig
抽象方法就行了。效果如下:
class RhombusPortPath extends PortPathBuilder with StokePathMixin{
// 略同...
@override
StokeConfig calculateConfig(Rect zone, double lineWidth) {
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomCenter;
double rad = (p1-p0).direction;
double offsetX = lineWidth/sin(rad);
double shapeWidth = zone.width;
double offsetEnd = offsetX;
double rate = (shapeWidth-offsetX-offsetEnd)/(shapeWidth);
return StokeConfig(offsetX: offsetX, rate: rate);
}
}
// 使用时:
StokePathMixin stokePath = const RhombusPortPath();
PortPathBuilder pathBuilder = StokeHandler(child: stokePath,lineWidth: 15);复制代码
大家可以自己动动小手,实现一下空心圆。
到这里关于箭头端点的设计内容就介绍地差不多了,draw.io
中还有一些花里胡哨的箭头这里就不一一介绍了。本文涉及了一些绘制技巧、数学几何计算以及对问题的抽象化,都是比较重要的。大家可以结合自己的思考,好好消化一下,那本文就到这里,后面还会继续探索一些关于箭头相关的有趣绘制,敬请期待。
原文链接:https://juejin.cn/post/7122610005408219172#heading-0
关注我获取更多知识或者投稿