当前位置:   article > 正文

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

flutter中arrowpainter

outside_default.png

上一篇 《Flutter 绘制探索 | 来一起画箭头吧》 ,实现了一个可以自由拓展的箭头绘制小体系。线和箭头的旋转已经封装好了,只需要在矩形端点矩形域中提供路径即可。本文我们就来对端点的箭头路径进行拓展,丰富箭头的样式,同时也更方便使用者调用。


毕竟用别人现成的要比自己绘制简单地多,也不是所有人都有绘制的能力。这个箭头小系列就是为了打造一个小巧、便捷的箭头绘制库。所以丰富箭头样式是其中主要的一环。

outside_default.png


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

outside_default.png

1. 箭头绘制环境

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

outside_default.png

这个背景绘制的代码如下所示,其实就是一些最基本的路径操作而已。对看过 《Flutter 绘制指南 - 妙笔生花》的朋友来说,这些都是小菜一碟:

  1. void main() {
  2. runApp(const ColoredBox(color: Colors.white, child: Painter()));
  3. }
  4. class Painter extends StatelessWidget {
  5. const Painter({Key? key}) : super(key: key);
  6. @override
  7. Widget build(BuildContext context) {
  8. return CustomPaint(
  9. painter: PortPathPainter(),
  10. );
  11. }
  12. }
  13. class PortPathPainter extends CustomPainter {
  14. @override
  15. void paint(Canvas canvas, Size size) {
  16. canvas.translate(size.width / 2, size.height / 2);
  17. Size zoneSize = const Size(150, 150);
  18. Rect zone = Rect.fromCenter(center: Offset.zero,
  19. width: zoneSize.width,
  20. height: zoneSize.height);
  21. drawHelp(canvas, zone);
  22. }
  23. final Paint _helpPaint = Paint()
  24. ..style = PaintingStyle.stroke
  25. ..color = const Color(0xffE0DEEC);
  26. void drawHelp(Canvas canvas, Rect zone) {
  27. Path path = Path();
  28. final double width = zone.width;
  29. final double height = zone.height;
  30. Rect partZone = Rect.fromCenter(center: Offset.zero,
  31. width: width * 0.5, height: height * 0.5);
  32. path..moveTo(-width / 2, -height / 2)..relativeLineTo(width, height);
  33. path..moveTo(-width / 2, height / 2)..relativeLineTo(width, -height);
  34. path..moveTo(-width / 2, height / 2)..relativeLineTo(width, -height);
  35. path..moveTo(0, -height / 2)..relativeLineTo(0, height);
  36. path..moveTo(-width / 4, -height / 2)..relativeLineTo(0, height);
  37. path..moveTo(width / 4, -height / 2)..relativeLineTo(0, height);
  38. path..moveTo(-width / 2, 0)..relativeLineTo(width, 0);
  39. path..moveTo(-width / 2, height / 4)..relativeLineTo(width, 0);
  40. path..moveTo(-width / 2, -height / 4)..relativeLineTo(width, 0);
  41. path.addRect(zone);
  42. path.addOval(partZone);
  43. path.addOval(zone);
  44. canvas.drawPath(path, _helpPaint);
  45. }
  46. @override
  47. bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  48. }复制代码

现在把上一篇的 ThreeAnglePortPath 放在背景中,如下所示,是不是有内味了。其中 rate 参数用于控制中间右侧点的偏移分率,该值越小,上下两个角越尖。

outside_default.png

  1. Path result = const ThreeAnglePortPath(rate: 0.75).fromPathByRect(zone);
  2. canvas.drawPath(result, _arrowPaint);复制代码

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

outside_default.png


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

outside_default.png

outside_default.png

2. 实心三角类型

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

outside_default.png

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

outside_default.png


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

outside_default.png

  1. class ThreeAngleLowPortPath extends PortPathBuilder{
  2. final double rate;
  3. final double lowRate;
  4. const ThreeAngleLowPortPath({this.rate = 0.75,this.lowRate=0.8});
  5. @override
  6. Path fromPathByRect(Rect zone) {
  7. Path path = Path();
  8. Offset p0 = zone.centerLeft;
  9. Offset p1 = zone.bottomRight.translate(0, -zone.height*lowRate);
  10. Offset p2 = zone.topRight.translate(0, zone.height*lowRate);
  11. Offset p3 = p0.translate(rate * zone.width, 0);
  12. path
  13. ..moveTo(p0.dx, p0.dy)
  14. ..lineTo(p1.dx, p1.dy)
  15. ..lineTo(p3.dx, p3.dy)
  16. ..lineTo(p2.dx, p2.dy)
  17. ..close();
  18. return path;
  19. }
  20. }复制代码

同理,下面的第四个可以由第三个,右侧顶点位移进行实现。但仔细观察和思考可以看出,第一个当 rate =1 时,就是第三个。第二个,当 lowRate = 0 就是第一个。也就是说这四种类型的箭头都可以通过 ThreeAngleLowPortPath 的不同配置来获得。

outside_default.png

所以并没有用分成四个类来单独处理,那么问题来了,只给一个 ThreeAngleLowPortPath ,在使用时语义并不怎么明确。使用者可能不知道怎么设置是哪种类型。dart 在构造方法中支持 命名构造 ,最常见的就是在 ListView 构造时,使用不同的构造,可以以不同的方式构造列表,这样语义就会很明确。这里可以进行借鉴:

  1. class TrianglePortPath extends PortPathBuilder {
  2. final double rate;
  3. final double lowRate;
  4. const TrianglePortPath({this.rate = 0.75, this.lowRate = 0.15});
  5. const TrianglePortPath.custom()
  6. : rate = 1,
  7. lowRate = 0;
  8. const TrianglePortPath.customLow({this.lowRate=0.15})
  9. : rate = 1;
  10. const TrianglePortPath.threeAngle({this.rate = 0.75})
  11. : lowRate = 0;
  12. @override
  13. Path fromPathByRect(Rect zone) {
  14. Path path = Path();
  15. Offset p0 = zone.centerLeft;
  16. Offset p1 = zone.bottomRight.translate(0, -zone.height * lowRate);
  17. Offset p2 = zone.topRight.translate(0, zone.height * lowRate);
  18. Offset p3 = p0.translate(rate * zone.width, 0);
  19. path
  20. ..moveTo(p0.dx, p0.dy)
  21. ..lineTo(p1.dx, p1.dy)
  22. ..lineTo(p3.dx, p3.dy)
  23. ..lineTo(p2.dx, p2.dy)
  24. ..close();
  25. return path;
  26. }
  27. }复制代码

比如创建三个尖角的样式,可以使用:

const TrianglePortPath.threeAngle(rate: 0.75)复制代码

outside_default.png


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

const TrianglePortPath.customLow()复制代码

outside_default.png

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


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

outside_default.png

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

outside_default.png

  1. class HalfTrianglePortPath extends PortPathBuilder {
  2. final double lineWidth;
  3. const HalfTrianglePortPath({required this.lineWidth});
  4. @override
  5. Path fromPathByRect(Rect zone) {
  6. Path path = Path();
  7. Offset p0 = zone.centerLeft.translate(0, lineWidth/2);
  8. Offset p1 = zone.centerRight.translate(0, lineWidth/2);
  9. Offset p2 = zone.topRight;
  10. path..moveTo(p0.dx, p0.dy)
  11. ..lineTo(p1.dx, p1.dy)
  12. ..lineTo(p2.dx, p2.dy)
  13. ..close();
  14. return path;
  15. }
  16. }复制代码

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

outside_default.png

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

outside_default.png

3. 线型箭头

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

outside_default.png


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

outside_default.png

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

outside_default.png

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

outside_default.png

  1. class TriangleLinePortPath extends PortPathBuilder {
  2. final double lineWidth;
  3. const TriangleLinePortPath({required this.lineWidth});
  4. @override
  5. String get debugLabel => "TriangleLinePortPath:[lineWidth:$lineWidth]";
  6. @override
  7. Path fromPathByRect(Rect zone) {
  8. Path path = Path();
  9. Offset p0 = zone.centerLeft;
  10. Offset p1 = zone.bottomRight;
  11. Offset p2 = zone.topRight;
  12. double radBR = (p1-p0).direction;
  13. double c = zone.height/2-cos(radBR)*lineWidth-lineWidth/2;
  14. path..moveTo(p0.dx, p0.dy)
  15. ..lineTo(p1.dx, p1.dy)
  16. ..relativeLineTo(sin(radBR)*lineWidth, -cos(radBR)*lineWidth)
  17. ..relativeLineTo(-c/(tan(radBR)), -c)
  18. ..relativeLineTo((c)/(tan(radBR)), 0)
  19. ..relativeLineTo(0, -lineWidth)
  20. ..relativeLineTo(-(c)/(tan(radBR)), 0)
  21. ..relativeLineTo(c/(tan(radBR)), -c)
  22. ..lineTo(p2.dx, p2.dy)..close();
  23. return path;
  24. }
  25. }复制代码

想要窄一些的箭头,同样也可以通过一个 lowRate 参数,对右侧上下两点进行偏移:

outside_default.png

加线效果如下:

outside_default.png


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

outside_default.png

  1. class HalfTriangleLinePortPath extends PortPathBuilder {
  2. final double lineWidth;
  3. const HalfTriangleLinePortPath({required this.lineWidth});
  4. @override
  5. Path fromPathByRect(Rect zone) {
  6. Path path = Path();
  7. Offset p0 = zone.centerLeft.translate(0, lineWidth/2);
  8. Offset p2 = zone.topRight;
  9. double radBR = (p2-p0).direction;
  10. double c = zone.height/2-cos(radBR)*lineWidth-lineWidth/2;
  11. path..moveTo(p0.dx, p0.dy)
  12. ..lineTo(p2.dx, p2.dy)
  13. ..relativeLineTo(-sin(radBR)*lineWidth, cos(radBR)*lineWidth)
  14. ..relativeLineTo(c/(tan(radBR)), c)
  15. ..relativeLineTo(-c/(tan(radBR)), 0)
  16. ..relativeLineTo(0, lineWidth)
  17. ..close();
  18. return path;
  19. }
  20. }复制代码

加线效果如下:

outside_default.png

4. 几何体填充

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

outside_default.png

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

outside_default.png


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

outside_default.png

  1. class RhombusPortPath extends PortPathBuilder {
  2. const RhombusPortPath();
  3. @override
  4. Path fromPathByRect(Rect zone) {
  5. Path path = Path();
  6. Offset p0 = zone.centerLeft;
  7. Offset p1 = zone.bottomCenter;
  8. Offset p2 = zone.centerRight;
  9. Offset p3 = zone.topCenter;
  10. path..moveTo(p0.dx, p0.dy)
  11. ..lineTo(p1.dx, p1.dy)
  12. ..lineTo(p2.dx, p2.dy)
  13. ..lineTo(p3.dx, p3.dy)
  14. ..close();
  15. return path;
  16. }
  17. }复制代码

对高度窄一些的菱形,同样可以通过 lowRate 对上下点进行竖直平移,效果如下:

outside_default.png

  1. class RhombusPortPath extends PortPathBuilder {
  2. final double lowRate;
  3. const RhombusPortPath({this.lowRate=0});
  4. @override
  5. Path fromPathByRect(Rect zone) {
  6. Path path = Path();
  7. Offset p0 = zone.centerLeft;
  8. Offset p1 = zone.bottomCenter.translate(0, -zone.height*lowRate);
  9. Offset p2 = zone.centerRight;
  10. Offset p3 = zone.topCenter.translate(0, zone.height*lowRate);
  11. path..moveTo(p0.dx, p0.dy)
  12. ..lineTo(p1.dx, p1.dy)
  13. ..lineTo(p2.dx, p2.dy)
  14. ..lineTo(p3.dx, p3.dy)
  15. ..close();
  16. return path;
  17. }
  18. }复制代码

5. 空心类型

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

outside_default.png

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


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

outside_default.png

  1. class StokeHandler extends PortPathBuilder {
  2. final PortPathBuilder child;
  3. final double lineWidth;
  4. StokeHandler({required this.child, required this.lineWidth});
  5. @override
  6. Path fromPathByRect(Rect zone) {
  7. Path outPath = child.fromPathByRect(zone);
  8. Offset p0 = zone.centerLeft;
  9. Offset p1 = zone.bottomRight;
  10. double rad = (p1-p0).direction;
  11. double offsetX = lineWidth/sin(rad);
  12. Matrix4 m4 = Matrix4.identity();
  13. double centerX = -zone.size.width/2+offsetX;
  14. double rate = (zone.width-offsetX-lineWidth)/zone.size.width;
  15. m4.multiply(Matrix4.translationValues(centerX, 0, 0));
  16. m4.multiply(Matrix4.diagonal3Values(rate, rate, 1));
  17. m4.multiply(Matrix4.translationValues(-centerX, 0, 0));
  18. m4.multiply(Matrix4.translationValues(offsetX, 0, 0));
  19. Path innerPath = outPath.transform(m4.storage);
  20. return Path.combine(PathOperation.difference, outPath, innerPath);
  21. }
  22. }复制代码

这样通过 StokeHandler 包裹 TrianglePortPath 对象,就可以实现其空心化。这是一种 可插拔 的模式,更具有灵活性,可以减少一个维度而引起的类暴涨。

outside_default.png

  1. PortPathBuilder pathBuilder = const TrianglePortPath.custom();
  2. pathBuilder = StokeHandler(child: pathBuilder,lineWidth: 15);复制代码

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

outside_default.png


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

outside_default.png

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

outside_default.png

  1. double shapeWidth = zone.width;
  2. double offsetEnd = lineWidth;
  3. if(child is TrianglePortPath){ // tag
  4. shapeWidth = zone.width-(1-(child as TrianglePortPath).rate) * zone.width;
  5. }
  6. double centerX = -zone.size.width/2+offsetX;
  7. double rate = (shapeWidth-offsetX-offsetEnd)/(shapeWidth);复制代码

6. 编程心法: 依赖于抽象而非具体实现

这是 依赖倒转原则 的内容,如果只是单纯泛泛而谈,可能很难说明问题。现在刚好这个案例触及到了该原则,可以顺便展开来说一下。首先用人话来说明一下,什么叫 依赖 。比如说,我依赖于电脑编程,说明一个对象(A)在完成功能时,需要借助另一个对象 (B) 的能力,这就是 A 类 依赖于 B 类 。那什么是 抽象 呢,比如 电脑 本身就是一个抽象的概念,它指代的是一类具有某些功能的事物,而不是指哪一类特定的电脑。比如这里的 StokeHandler 依赖于 PortPathBuilder ,是因为需要使用它来创建路径。

outside_default.png


依赖于抽象,有一个弊端。因为依赖抽象的这个行为本身,就表明我们主动放弃了对实现类中独有数据的访问。比如上面在 StokeHandler 中除了获取路径外,需要使用实现类中的数据,这就说明这里的抽象没有做好,有些需要的功能并没有抽象出来。一开始未考虑周全也是很正常的事,下面我们来探讨一下,空心路径还需要依赖什么?


如下是对菱形的分析,从中可以看出 rad 的角度、offsetEnd 的长度和上面的情况都不同。这里就很考验我们的对问题的理解能力和抽象能力,也就是摒弃一些细枝末节,一针见血地提供最为必要的功能。比如这里 rad 角度是为了计算 offsetX ,offsetEnd 是为了计算缩放比例,这两个都不是问题的核心。

outside_default.png

最核心的配置信息是 偏移距离: offsetX 和 缩放比例:rate 。如下,定义一个 StokePathMixin 接口来对这两个数据的获取进行抽象。

  1. mixin StokePathMixin on PortPathBuilder{
  2. StokeConfig calculateConfig(Rect zone,double lineWidth);
  3. }
  4. class StokeConfig{
  5. final double offsetX;
  6. final double rate;
  7. StokeConfig({required this.offsetX,required this.rate});
  8. }复制代码

这样在 StokeHandler 中依赖于 StokePathMixin ,就可以使用 calculateConfig 获取 StokeConfig 。这就是依赖于抽象的好处,这里运行时 StokeConfig 是什么,fromPathByRect 中是不关心的,我只知道该对象可以通过 calculateConfig 获取 StokeConfig ,来保证后续的平移缩放任务能够完成。

  1. class StokeHandler extends PortPathBuilder {
  2. final StokePathMixin child;
  3. final double lineWidth;
  4. StokeHandler({required this.child, required this.lineWidth});
  5. @override
  6. Path fromPathByRect(Rect zone) {
  7. Path outPath = child.fromPathByRect(zone);
  8. Matrix4 m4 = Matrix4.identity();
  9. StokeConfig config = child.calculateConfig(zone, lineWidth);
  10. double centerX = -zone.size.width/2+config.offsetX;
  11. double rate = config.rate;
  12. m4.multiply(Matrix4.translationValues(centerX, 0, 0));
  13. m4.multiply(Matrix4.diagonal3Values(rate, rate, 1));
  14. m4.multiply(Matrix4.translationValues(-centerX, 0, 0));
  15. m4.multiply(Matrix4.translationValues(config.offsetX, 0, 0));
  16. Path innerPath = outPath.transform(m4.storage);
  17. return Path.combine(PathOperation.difference, outPath, innerPath);
  18. }
  19. }复制代码

比如对于菱形路径而言,可以混入 StokePathMixin ,表示它支持空心化。然后自己实现 calculateConfig 抽象方法就行了。效果如下:

outside_default.png

  1. class RhombusPortPath extends PortPathBuilder with StokePathMixin{
  2. // 略同...
  3. @override
  4. StokeConfig calculateConfig(Rect zone, double lineWidth) {
  5. Offset p0 = zone.centerLeft;
  6. Offset p1 = zone.bottomCenter;
  7. double rad = (p1-p0).direction;
  8. double offsetX = lineWidth/sin(rad);
  9. double shapeWidth = zone.width;
  10. double offsetEnd = offsetX;
  11. double rate = (shapeWidth-offsetX-offsetEnd)/(shapeWidth);
  12. return StokeConfig(offsetX: offsetX, rate: rate);
  13. }
  14. }
  15. // 使用时:
  16. StokePathMixin stokePath = const RhombusPortPath();
  17. PortPathBuilder pathBuilder = StokeHandler(child: stokePath,lineWidth: 15);复制代码

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

outside_default.png

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

原文链接:https://juejin.cn/post/7122610005408219172#heading-0

关注我获取更多知识或者投稿

0aa9c210dfe45aa6b15cff5a7b64c425.jpeg

cd5cf765633d4e18e042575232ba96c9.jpeg

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/很楠不爱3/article/detail/341792
推荐阅读
相关标签
  

闽ICP备14008679号