上一篇 《Flutter 绘制探索 | 来一起画箭头吧》 ,实现了一个可以自由拓展的箭头绘制小体系。线和箭头的旋转已经封装好了,只需要在矩形端点矩形域中提供路径即可。本文我们就来对端点的箭头路径进行拓展,丰富箭头的样式,同时也更方便使用者调用。
毕竟用别人现成的要比自己绘制简单地多,也不是所有人都有绘制的能力。这个箭头小系列就是为了打造一个小巧、便捷的箭头绘制库。所以丰富箭头样式是其中主要的一环。

Flutter 绘制探索 | 箭头端点的设计


draw.io 是我最喜欢的绘制软件,没有之一,本文就其中的一些常用箭头端点样式进行实现。通过仿写,可以对其中的箭头进行一些额外的参数配置,来满足更多的配置需求。这就是代码由自己掌控的好处,想实现什么可以自己动手,丰衣足食。

Flutter 绘制探索 | 箭头端点的设计


1. 箭头绘制环境

打个比方,我要造火箭的螺丝,并没有必要在火箭生产的现场去制作。在车间中根据图纸,按照尺寸制作就行了,造完后拿过去拧上就行了。这就是对复杂场景进行分离,将相对独立的生产对象独立出来,这样能够简化场景,更专注于做一件事。就像本文,我只想专注做一件事,就是如何在一块矩形区域内,来创建各种各样的箭头路径。
为了让我们对箭头的生产有那么一点 设计感 ,这里画个如下的辅助路径,对矩形区域进行示意。正所谓,磨刀不误砍柴工,通过这个 4*4 的圈格,可以让设计更直观。这就像是一个坐标,一个参考线,能给我们一些 安全

Flutter 绘制探索 | 箭头端点的设计

这个背景绘制的代码如下所示,其实就是一些最基本的路径操作而已。对看过 《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 参数用于控制中间右侧点的偏移分率,该值越小,上下两个角越尖。

Flutter 绘制探索 | 箭头端点的设计

Path result = const ThreeAnglePortPath(rate: 0.75).fromPathByRect(zone);
canvas.drawPath(result, _arrowPaint);

后面,每种箭头模式,会给出三个图示:辅助线内填充图、辅助线内校稿图和产品图。这样是不是让平平无奇的箭头绘制增加了一丢丢的逼格。

Flutter 绘制探索 | 箭头端点的设计


现在看一下上一篇中实现的 CustomPortPathCirclePortPath 两种箭头端点的效果。这样就能很清晰地看出端点路径在矩形区域内的具体表现:

Flutter 绘制探索 | 箭头端点的设计

Flutter 绘制探索 | 箭头端点的设计


2. 实心三角类型

draw.io 中,有如下五种实心三角相关的箭头,我们已经实现了两个。在实现其他的类型之前,我们需要思考一个问题。在端点的设计中,是否将绘制区域规范为正方形。这个问题会影响对高度较窄箭头的实现方式。

Flutter 绘制探索 | 箭头端点的设计

区域尺寸是由使用者传入的,如下使用红框和蓝框,在对路径生成的方式是不同的。这里我更倾向于使用正方形区域,这样更容易进行统一绘制的标准。端点不再传入 Size 尺寸,而是正方形的边长,这和 Icon 的尺寸是类似的。我们在设计中,将区域默认是 正方形 ,可以避免很多不必要的尺寸问题,在显示上也没什么区别。

Flutter 绘制探索 | 箭头端点的设计


如下,是高度较窄的箭头绘制示意,只需要在形成路径时对右侧上方两点进行竖直平移即可。这样定义了一个 lowRate 参数用于配置偏移的比率。如下 0.15 表示右上角向下平移了 矩形高*0.15 的距离。

Flutter 绘制探索 | 箭头端点的设计

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 的不同配置来获得。

Flutter 绘制探索 | 箭头端点的设计

所以并没有用分成四个类来单独处理,那么问题来了,只给一个 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)

Flutter 绘制探索 | 箭头端点的设计


创建矮一些的三角形,可以使用:

const TrianglePortPath.customLow()

Flutter 绘制探索 | 箭头端点的设计

这样即避免了类的数量随意增加,又可以有较好的语义,这就是 dart 命名构造的优势。我们在日常开发中,也可以尝试在适当的时候进行借鉴。


下面我们来看半个三角的样式,睁大眼睛仔细分析可以看到:右侧三角的左侧起点并不在矩形区域的水平中轴线上,它是与线的下部对齐的。使用半个三角的样式,我们需要知道线的宽度。

Flutter 绘制探索 | 箭头端点的设计

实现效果如下,只要将左侧和右下角的顶点,向下移动 半线宽 即可。通过辅助线就可以看的非常清楚:

Flutter 绘制探索 | 箭头端点的设计

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;
  }
}

加上线后,就是如下的效果,断点下方下移了半线宽,才可以保证和线的底部对齐:

Flutter 绘制探索 | 箭头端点的设计

如果不进行处理,就会发生如下很不和谐的情况:

Flutter 绘制探索 | 箭头端点的设计


3. 线型箭头

下面我们来看这一组,其中箭头粗细和线一致。这看着比较简单,但想要获取对应的路径,还是需要一些处理技巧的。

Flutter 绘制探索 | 箭头端点的设计


实现的效果图如下,代码是通过对路径移动实现的。其中比较困难的是:对线宽长度垂线数据的计算。

Flutter 绘制探索 | 箭头端点的设计

也就是在这两处路径相对移动时的数据,需要用到一些三角函数的处理:

Flutter 绘制探索 | 箭头端点的设计

如下,是对 A -> B -> P 移动中相关位移尺寸的分析图。另外上方那一块和下面是镜像的,下面处理好了,上面就非常简单,可以结合下方的代码体会一下:

Flutter 绘制探索 | 箭头端点的设计

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 参数,对右侧上下两点进行偏移:

Flutter 绘制探索 | 箭头端点的设计

加线效果如下:

Flutter 绘制探索 | 箭头端点的设计


最后,半线和上面处理逻辑也没有太大的差异。不过,和半三角一样,要注意底部下移半线宽,

Flutter 绘制探索 | 箭头端点的设计

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;
  }
}

加线效果如下:

Flutter 绘制探索 | 箭头端点的设计


4. 几何体填充

接下来看一组实心的几何体,这相对而言是比较简单的,不涉及复杂的计算。

Flutter 绘制探索 | 箭头端点的设计

其中圆形我们在上一篇已经实现过了:

Flutter 绘制探索 | 箭头端点的设计


如下是菱形的端点效果,实现非常简单,连接四边中点形成路径即可:

Flutter 绘制探索 | 箭头端点的设计

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 对上下点进行竖直平移,效果如下:

Flutter 绘制探索 | 箭头端点的设计

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. 空心类型

如下所示,填充的箭头都有与之对应的空心类型,其特点是外框和线宽一致。对空心路径的实现,我是遇到了一些小挫折的。因为我并不想对空心图形一个个实现,而是希望寻找到一个 通法 来处理,毕竟外框的路径是之前实现过的,再一一计算内框进行合并,感觉比较复杂,也会导致类的增多。

Flutter 绘制探索 | 箭头端点的设计

这里有两个变化的维度,有点像 桥接模式 需要解决的问题,不过这里只有线型和填充两种模式,并不需要进行其他的拓展,所以 桥接模式 有点大材小用。我们可以这个 装饰者模式 ,通过包裹一层,来达到增加特定功能的目的。


解决空心类型的方案是 缩放 + 裁剪 。下图是对基本三角的分析,核心就是基于线宽,计算出缩放比例。这是一个非常精细的计算过程,主要是确定内层路径端点偏移量 offsetX 。将缩放的变换中心移动到如下红点处,进行缩放变换。最后偏移 offsetX 即可:

Flutter 绘制探索 | 箭头端点的设计

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 对象,就可以实现其空心化。这是一种 可插拔 的模式,更具有灵活性,可以减少一个维度而引起的类暴涨。

Flutter 绘制探索 | 箭头端点的设计

PortPathBuilder pathBuilder = const TrianglePortPath.custom();
pathBuilder = StokeHandler(child: pathBuilder,lineWidth: 15);

理想很丰满,现实很骨感。当使用测试三尖角的模式,发现出问题了,理论上应该是缩放比例计算有误。

Flutter 绘制探索 | 箭头端点的设计


仔细分析一下这个图形,可以发现对于不同的箭头样式 shapeWidth 是不同的,这是导致缩放比例错误的根源。但 StokeHandler 在设计之初,只在意路径的产生,所以并无法感知到 PortPathBuilder 实现类的内部数据。

Flutter 绘制探索 | 箭头端点的设计

有一种笨方法,根据 child 的运行时类型,进行强转,从而获取内部数据,进行额外处理,如下 tag 处所示。这虽然不太优雅,但确实实现了需求。

Flutter 绘制探索 | 箭头端点的设计

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 ,是因为需要使用它来创建路径。

Flutter 绘制探索 | 箭头端点的设计


依赖于抽象,有一个弊端。因为依赖抽象的这个行为本身,就表明我们主动放弃了对实现类中独有数据的访问。比如上面在 StokeHandler 中除了获取路径外,需要使用实现类中的数据,这就说明这里的抽象没有做好,有些需要的功能并没有抽象出来。一开始未考虑周全也是很正常的事,下面我们来探讨一下,空心路径还需要依赖什么?
如下是对菱形的分析,从中可以看出 rad 的角度、offsetEnd 的长度和上面的情况都不同。这里就很考验我们的对问题的理解能力和抽象能力,也就是摒弃一些细枝末节,一针见血地提供最为必要的功能。比如这里 rad 角度是为了计算 offsetXoffsetEnd 是为了计算缩放比例,这两个都不是问题的核心。

Flutter 绘制探索 | 箭头端点的设计

最核心的配置信息是 偏移距离: 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 抽象方法就行了。效果如下:

Flutter 绘制探索 | 箭头端点的设计

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);

大家可以自己动动小手,实现一下空心圆。

Flutter 绘制探索 | 箭头端点的设计

到这里关于箭头端点的设计内容就介绍地差不多了,draw.io 中还有一些花里胡哨的箭头这里就不一一介绍了。本文涉及了一些绘制技巧、数学几何计算以及对问题的抽象化,都是比较重要的。大家可以结合自己的思考,好好消化一下,那本文就到这里,后面还会继续探索一些关于箭头相关的有趣绘制,敬请期待。

  • 我正在参与技术社区创作者签约计划招募活动,点击链接报名投稿。