theme: cyanosis
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 10 天,点击查看活动详情
0. 前言
今天来探索一个问题,如何绘制一块扇形区域路径,并且校验触点是否落在 扇形区域
之中。这个问题对于绘制 饼图
及处理手势事件校验非常重要。
1. 扇形区域的定义
首先来明确一下扇形区域的表示,如下图所示,一个 扇形区域
通过五个属性进行描述:
| 属性名 | 类型 | 作用 | | --- | --- |--- | | center | Offset | 扇心| | innerRadius | double | 小圆半径 | | outRadius | double | 大圆半径 | | startAngle | double | 起始角度: 与横纵夹角(弧度) | | sweepAngle | double | 扫描角度: 弧度值,顺时针为正 |
这里通过 SectorShape
类对扇区的属性进行维护, 定义如下:
```dart class SectorShape { Offset center; // 中心点 double innerRadius; // 小圆半径 double outRadius; // 大圆半径 double startAngle; // 起始弧度 double sweepAngle; // 扫描弧度
SectorShape({ required this.center, required this.innerRadius, required this.outRadius, required this.startAngle, required this.sweepAngle, }); } ```
2. 绘制扇形区域
接下来看一下如何绘制扇形区域,思路是先生成 区域路径
,然后绘制路径。在生成路径的过程中,需要知道四个端点的坐标,如下所示:
根据 SectorShape
的属性,可以很轻松地计算出四点的坐标,如下所示:其中 shape
是 SectorShape
类型对象:
``` double startRad = shape.startAngle; double endRad = shape.startAngle + shape.sweepAngle; double r0 = shape.innerRadius; double r1 = shape.outRadius;
Offset p0 = Offset(cos(startRad) * r0, sin(startRad) * r0); Offset p1 = Offset(cos(startRad) * r1, sin(startRad) * r1); Offset q0 = Offset(cos(endRad) * r0, sin(endRad) * r0); Offset q1 = Offset(cos(endRad) * r1, sin(endRad) * r1); ```
下面是通过四点形成扇形区域路径的过程,其中 arcToPoint
能做出指定终点的圆弧路径,详细介绍可免费参见 : 《妙笔生花-第五章》 相关方法。
dart Path path = Path() ..moveTo(p0.dx, p0.dy) ..lineTo(p1.dx, p1.dy) ..arcToPoint(q1, radius: Radius.circular(r1), clockwise: false) ..lineTo(q0.dx, q0.dy) ..arcToPoint(p0, radius: Radius.circular(r0));
由于 SectorShape
的属性能唯一对应一种扇形区域。 使用为了使用方便,可以在 SectorShape
中提供一个 formPath
来生成路径:另外要注意,需要根据 sweepAngle
的正负确定顺时针与否;根据 sweepAngle
是否大于 pi
,确定区分取大弧。最后,根据 center
坐标对路径进行平移操作。
``` ---->[SectorShape#formPath]---- Path formPath() { double startRad = startAngle; double endRad = startAngle + sweepAngle;
double r0 = innerRadius; double r1 = outRadius; Offset p0 = Offset(cos(startRad) * r0, sin(startRad) * r0); Offset p1 = Offset(cos(startRad) * r1, sin(startRad) * r1); Offset q0 = Offset(cos(endRad) * r0, sin(endRad) * r0); Offset q1 = Offset(cos(endRad) * r1, sin(endRad) * r1);
bool large = sweepAngle.abs() > pi; bool clockwise = sweepAngle > 0;
Path path = Path() ..moveTo(p0.dx, p0.dy) ..lineTo(p1.dx, p1.dy) ..arcToPoint(q1, radius: Radius.circular(r1), clockwise: clockwise, largeArc: large) ..lineTo(q0.dx, q0.dy) ..arcToPoint(p0, radius: Radius.circular(r0), clockwise: !clockwise, largeArc: large); return path.shift(center); } ```
这样就可以很轻松的通过 SectorShape
对象,来展示一个扇形区域。其中你可以通过操作 Paint
画笔,来实现更多的效果:比如使用的 shader
在扇形区域内填充图片、渐变等,这些基础可参见小册。
| 填充颜色 | 填充图像1 | 填充图像1 | | --- | --- |--- | | | ||
``` Paint paint = Paint() ..style = PaintingStyle.stroke ..color = Colors.blue ..strokeWidth = 2;
SectorShape shape = SectorShape( center: Offset(size.width / 2, size.height / 2), innerRadius: 40, outRadius: 80, startAngle: 30 * pi / 180, sweepAngle: -80 * pi / 180, );
canvas.drawPath(shape.formPath(), paint); ```
3. 扇形区域的点击校验
下面来思考一个问题:当手指或鼠标点在界面上,如何校验该点是否在 扇形区域
之内。如下图,很明显 p1
在其中,p2
不在。如何通过代码进行校验呢?
其实,思路很简单,点落在扇心区域内,需要满足两个条件:
[1]. 扇心与落点的距离 d 在 [innerRadius,outRadius]。 [2]. 扇心与落点的夹角在 [startAngle,startAngle+sweepAngle] 之间。
由于扇形区域的信息都存储在 SectorShape
类中,所以可以在其中定义 contains
方法,用于校验点是否在扇形区内:
---->[SectorShape#contains]---- bool contains(Offset p) { // Todo }
下面,来实现如下效果,在按下时,落点在扇心区域内时,区域显示填充色示意,抬起时恢复:
校验逻辑如下,其中 校验环形区域
非常简单,落点与中心距离算出来比较一下即可。如果不再环中,就可以立刻判定为失败并返回。关于角度的校验:逆时针扫描时,终点小于起点;顺时针扫描时,终点大于起点;所以要根据 sweepAngle
区分判断:
```dart ---->[SectorShape#contains]---- bool contains(Offset p) { // 校验环形区域 double l = (p - center).distance; bool inRing = l <= outRadius && l >= innerRadius; if (!inRing) return false;
// 校验角度范围 double a = (p - center).direction; double endArg = startAngle + sweepAngle; double start = startAngle; if(sweepAngle > 0){ return a >= start && a <= endArg; }else{ return a <= start && a >= endArg; } } ```
这样在绘制时,只要通过下面 tag1
处代码,使用 shape.contains
方法,就能校验 p
点是否在扇形区内,如果在,则绘制扇形填充。核心的逻辑就是这些,想看详细的效果可参见源码: 【sector/02】
```dart ---->[Paper#paint]---- SectorShape shape = SectorShape( center: Offset(size.width / 2, size.height / 2), innerRadius: 40, outRadius: 80, startAngle: 30 * pi / 180, sweepAngle: 280 * pi / 180, );
bool contain = shape.contains(p); // tag1
if(contain){ canvas.drawPath(shape.formPath(), paint); Paint paint2 = Paint()..style=PaintingStyle.stroke; canvas.drawPath(shape.formPath(), paint2); } ```
4. 几何校验与 Path 校验的区别
有些聪明的小伙伴可能会问:
能问出这个问题,说明对绘制的基础掌握的还是比较牢固的。Path#contains
方法对于不规则的图形校验是至上法宝。但对于标准图形,通过几何方法进行校验比较简单,就像到楼下超市买瓶饮料,没必要开车去买。
为此,我做了一个小测试,看看两者在 百万次
校验下的表现。如下 tag1
是使用 Path
判断,tag2
是上面基于几何型的判断。进行了两组四项测试,表现如下:
| 百万次耗时 | Path 校验 | 几何形校验| | --- | --- |--- | | 点不在区域 | 230 ms | 35 ms | | 点在区域 | 480 ms | 110 ms |
```dart SectorShape shape = SectorShape( center: Offset.zero, innerRadius: 40, outRadius: 80, startAngle: 30 * pi / 180, sweepAngle: 80 * pi / 180, );
// Offset offset = const Offset(112.7, 148.4); Offset offset = const Offset(0, 0); Path path = shape.formPath(); int time = DateTime.now().millisecondsSinceEpoch; for (int i = 0; i < 1000000; i++) { // path.contains(offset); // tag1 // shape.contains(offset); // tag2 } int endTime = DateTime.now().millisecondsSinceEpoch; print('${endTime - time}ms'); ```
可以看出通过几何形的判断要快一些,这也是最直接的方式。当初步校验不合格,可以直接结束判断,而且其中只是基本的运算符计算,没有涉及复杂的循环判断。对于标准图形来说,这种方式既有效,又便捷,是比较好的。
但千万别会错意,我并不是说 path.contains
方法耗时,百万次才耗时两三百毫秒,如果不是超大批量的路径遍历校验,基本上也没什么影响。一般界面上同时校验几十个路径就顶天了,所以也不用担心,就像一秒赚几百万,不必要为丢了一毛钱而忧心忡忡。
到这里,扇形区域路径的获取、绘制与点击校验就完成了。对于 饼状图
而言,相当于最基础的材料已经准备完毕。下一篇,将基于本文的扇形区域,简单实现一个 饼状统计图
。那本文就到这里,谢谢观看 ~
更多 Flutter 绘制技巧,欢迎关注 《Flutter 绘制探索》 专栏。
@张风捷特烈 2022.10.27 未允禁转
我的 公众号: 编程之王
我的 github 主页
: toly1994328