Flutter 绘制番外篇 - 数学中的角度知识

本文介绍了如何利用Flutter进行图形绘制,涉及两点间角度计算、线段旋转、图片绑定线段及动态效果实现。通过Offset的direction属性计算角度,并展示了如何围绕任意点旋转线段,以及计算线段上特定位置的坐标。文章还探讨了如何通过数据驱动方式实现图形联动,提供了一系列实用的绘制技巧和代码示例。
摘要由CSDN通过智能技术生成
前言

对一些有趣的绘制技能知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”“活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。

另外 [番外篇] 的文章是完全公开免费的,也会同时在普通文章中发表,且 [番外篇] 会在普通文章发布三日后入驻小册,这样便于错误的暴露收集建议反馈。本文作为 [番外篇] 之一,主要来探讨一下角度坐标 的知识。


一、两点间的角度

你有没有想过,两点之间的角度如何计算。比如下面的 p0p1 点间的角度,也就是两点之间的斜率。这上过初中的人都知道,使用 反三角函数 算一下就行了。那其中有哪些坑点要注意呢,下面一方面学知识,一方面练画技,一起画画吧!


1. 把线信息画出来

首先来画出如下效果,点 p0(0,0) ;点 p1(60,60)

为了方便数据管理,将起止点封装在 Line 类中。其中黑色部分的线体Line 类承担,这样在就能减少画板的绘制逻辑。

```dart class Line { Line({ this.start = Offset.zero, this.end = Offset.zero, });

Offset start; Offset end;

final Paint pointPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1;

void paint(Canvas canvas){ canvas.drawLine(Offset.zero, end, pointPaint); drawAnchor(canvas,start); drawAnchor(canvas,end); }

void drawAnchor(Canvas canvas, Offset offset) { canvas.drawCircle(offset, 4, pointPaint..style = PaintingStyle.stroke); canvas.drawCircle(offset, 2, pointPaint..style = PaintingStyle.fill); } } ```


画板是 AnglePainter ,其中虚线通过我的 dash_painter 库进行绘制,定义 line 对象之后,在 paint 方法中通过 line.paint(canvas); 即可绘制黑色的线体部分,蓝色的辅助信息通过 drawHelp 进行绘制。这样通过改变 line 对象的点位就可以改变线体绘制,如下是 p1 点变化对应的绘制表现:

| p1(60,60) | p1(60,-80) | p1(-60,-80) | p1(-60,80) | | ------ | ------- | ------ | ---- | | | | image-20210902212856156 | image-20210902212949081 |

```dart class AnglePainter extends CustomPainter { // 绘制虚线 final DashPainter dashPainter = const DashPainter(span: 4, step: 4);

final Paint helpPaint = Paint() ..style = PaintingStyle.stroke..color = Colors.lightBlue..strokeWidth = 1;

final TextPainter textPainter = TextPainter( textAlign: TextAlign.center, textDirection: TextDirection.ltr, );

Line line = Line(start: Offset.zero, end: const Offset(60, 60));

@override void paint(Canvas canvas, Size size) { canvas.translate(size.width / 2, size.height / 2); drawHelp(canvas, size); line.paint(canvas); }

void drawHelp(Canvas canvas, Size size) { Path helpPath = Path() ..moveTo(-size.width / 2, 0) ..relativeLineTo(size.width, 0); dashPainter.paint(canvas, helpPath, helpPaint); drawHelpText('0°', canvas, Offset(size.width / 2 - 20, 0)); drawHelpText('p0', canvas, line.start.translate(-20, 0)); drawHelpText('p1', canvas, line.end.translate(-20, 0)); }

void drawHelpText( String text, Canvas canvas, Offset offset, { Color color = Colors.lightBlue }) { textPainter.text = TextSpan( text: text, style: TextStyle(fontSize: 12, color: color), ); textPainter.layout(maxWidth: 200); textPainter.paint(canvas, offset); }

@override bool shouldRepaint(covariant CustomPainter oldDelegate) { return false; } } ```


2.角度计算

Flutter 中的 Offset 对象有 direction 属性,它是通过 atan2 反正切函数进行计算的。下面来看一下通过 direction 属性获取的角度特点。

```dart class Line { // 略同...

double get rad => (end-start).direction; }

---->[源码: Offset#direction]---- double get direction => math.atan2(dy, dx); ```

下面将计算出的弧度,转化为角度值,标注在左上角。源码中对 direction 属性的介绍是: 在 x 轴右向为正,y 轴向下为正的坐标系下,该偏移角度以是从 x 正轴顺时针方向偏移弧度,范围在 [-pi,pi] 之间。也就是说,x 轴的上部分的角度是负值 ,如下面的 34 图所示。

| p1(60,60) | p1(-60,80) | p1(-60,-60) | p1(60,-80) | | ------ | ------- | ------ | ---- | | | | | |

dart drawHelpText( '角度: ${(line.rad * 180 / pi).toStringAsFixed(2)}°', canvas, Offset( -size.width / 2 + 10, -size.height / 2 + 10, ), );


这里角度在 [-pi,pi] 之间,那我们能不能让它在 [0,2*pi]之间呢?这样比较符合 0~360° 的常归认识。其实很简单,如果为负,加个 2*pi 就行了,如下 positiveRad 的处理。

```dart ---->[Line]---- double get rad => (end - start).direction;

double get positiveRad => rad < 0 ? 2 * pi + rad : rad; ```


3.角度的使用

现在来做一个小案例,如下:通过两点间的角度来决定矩形旋转的角度,使用动画将 p1 点绕 p0 做圆周运动。由于两点的角度变化,矩形也会伴随旋转。

为了让 Line 的变化方便通知画板进行更新,这里让它继承自 ChangeNotifier ,成为可监听对象。并给出一个 rotate 方法,传入角度来更新坐标。这里为了方便,先以 0,0 为起点,只变更 end 坐标,已知 p1 做圆周运动,所以两点间距离不变,又知道了旋转角度,那 p1 在旋转 rad 时,p1 的坐标就很容易得出:

```dart class Line with ChangeNotifier { // 略同...

double get length => (end - start).distance;

void rotate(double rad) { end = Offset(length * cos(rad), length * sin(rad)); notifyListeners(); } } ```


上面实现了椭圆的角度伴随运动,那想一下,如何动态绘制如下的线与水平正方向的圆弧呢?

其实很简单,我们已经知道了角度值,通过 canvas.drawArc 就可以根据先的角度绘制圆弧。

dart ---->[AnglePainter#drawHelp]---- canvas.drawArc( Rect.fromCenter(center: Offset.zero, width: 20, height: 20), 0, line.positiveRad, false, helpPaint, );


4. 点任意的绕点旋转

其实刚才的圆周运动是一个及其特殊的情况,也就是线的起点在原点,且初始夹角为 0。这样在坐标计算时,不必考虑初始角度的影响。但对于一般场合,上面的运算方式会出现错误。那如何实现 p0 点的任意呢?其实这就是移到简单的初中数学题:

dart 已知: p0(a,b)、p1(c,d),求 p1 绕 p0 顺时针旋转 θ 弧度后得到 p1' 点。 求: p1' 点的坐标。

 其实算起来很简单,如下,旋转了 θ 弧度后得到 p1' 。以 p0 为参考系原点的话,p1' 的坐标呼之欲出。

``` 令两点间角度为 rad, 两点间距离为 length, 则: p1': (lengthcos(rad+θ),lengthsin(rad+θ))

已知 p0 坐标为 start,则以 (0,0) 为坐标系,则 p1': (lengthcos(rad+θ),lengthsin(rad+θ)) + start ```

由于 rotate 参数是总的旋转角度,而rotate 方法每次触发都会更新 end 的坐标,所以 rad 会不断更新,我们需要处理的是每次动画触发间的旋转角度,即下面的 detaRotate 。本案例完整源码见: rad_rotate

dart double detaRotate = 0; void rotate(double rotate) { detaRotate = rotate - detaRotate; end = Offset( length * cos(rad + detaRotate), length * sin(rad + detaRotate), ) + start; detaRotate = rotate; notifyListeners(); }


二、你的点又何须是点

也许上面在你眼中,这些只是点的运算而已,但在我眼中,它们是一种约束绑定关系,因为运算本身就是约束法则。两个点数据构成一种结构,一种骨架,那你所见的点,又何须是点呢?


1. 绘制箭头

如下,是绘制箭头的案例:界面上所展现的,是Line#paint 方法绘制的内容,只要通过两个点所提供的信息,绘制出箭头即可。绘制逻辑是:先画一个水平箭头,再根据旋转角度,绕 p0 旋转。

dart void paint(Canvas canvas) { canvas.save(); canvas.translate(start.dx, start.dy); canvas.rotate(positiveRad); Path arrowPath = Path(); arrowPath ..relativeLineTo(length - 10, 3) ..relativeLineTo(0, 2) ..lineTo(length, 0) ..relativeLineTo(-10, -5) ..relativeLineTo(0, 2)..close(); canvas.drawPath(arrowPath,pointPaint); canvas.restore(); }

这样,点位数据的变化,同样可以驱动绘制的变化。本案例完整源码见: arrow


2. 绘制图片

如下是一张图片,现在通过 PS 获取胳膊的区域数据:0, 93, 104, 212 。左上角和左下角两点构成直线,如果我们根据点的位置信息,来绘制图片会怎么样呢?

为了储存图片和区域信息,下面定义 ImageZone 对象,在构造中传入图片 image 和区域 rect 。另外通过 imagerect ,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象

```dart import 'dart:ui'; import 'line.dart';

class ImageZone { final Image image; final Rect rect;

Line? _line;

ImageZone({required this.image, this.rect = Rect.zero});

Line get line { if (_line != null) { return _line!; } Offset start = Offset( -(image.width / 2 - rect.right), -(image.height / 2 - rect.bottom)); Offset end = start.translate(-rect.width, -rect.height); _line = Line(start: start, end: end); return _line!; } } ```


ImageZone 中定义一个 paint 方法,通过 canvasline 进行图片的绘制。这样方便在 Line类中进行图片绘制,简化 Line 的绘制逻辑。

dart ---->[ImageZone]---- void paint(Canvas canvas, Line line) { canvas.save(); canvas.translate(line.start.dx, line.start.dy); canvas.rotate(line.positiveRad - this.line.positiveRad); canvas.translate(-line.start.dx, -line.start.dy); canvas.drawImageRect( image, rect, rect.translate(-image.width / 2, -image.height / 2), imagePaint, ); canvas.restore(); }


Line 类中,添加一个 attachImage 方法,将 ImageZone 对象关联到 Line对象上。在 paint中只需要通过 _zone 对象进行绘制即可。

```dart ---->[Line]---- class Line with ChangeNotifier { // 略同...

ImageZone? _zone;

void attachImage(ImageZone zone) { _zone = zone; start = zone.line.start; end = zone.line.end; notifyListeners(); }

void paint(Canvas canvas) { // 绘制箭头略.... _zone?.paint(canvas, this); } ```

这样我们就可以将图片的某个矩形区域 附魔 到一个线段上。手的图片通过 _loadImage 来加载,并通过 attachImage 方法为 line 对象 附魔

dart void _loadImage() async { ByteData data = await rootBundle.load('assets/images/hand.png'); List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); _image = await decodeImageFromList(Uint8List.fromList(bytes)); line.attachImage(ImageZone( rect: const Rect.fromLTRB(0, 93, 104, 212), image: _image!, )); }

同样,可以让线段绕起点进行旋转,如下的挥手动作。

dart void _updateLine() { line.rotate(ctrl.value * 2* pi/50); }

将背景图片进行绘制,就可以得到一个完整的效果。本案例完整源码见: body


三、线绕任意点旋转

下面我们来如何让已知线段按照某个点,进行旋转,这个问题等价于:

已知,p0、p1、p2点坐标,线段 p0、p1 绕 p2 顺时针旋转 θ 弧度后的到 p0'、p1'。 求:p0'、p1' 坐标。


1.问题分析

由于两点确定一条直线,线段 p0、p1p2旋转,等价于 p0p1 分别绕 p2 旋转。示意图如下:

对应于代码,就是在 rotate 方法中,传入一个坐标 centre ,根据该坐标和旋转角度,对 p0p1 点进行处理,得到新的点。

dart void rotate(double rotate,{Offset? centre}) { //TODO }


2.解决方案和代码处理

之前已经处理了绕起点旋转的逻辑,这里我们可以用一个非常巧妙的方案:

求 p0’ 的坐标,可以构建 p2,p0 线段,让该线段执行旋转逻辑,其 end 坐标即是 p0’。 求 p1’ 的坐标,可以构建 p2,p1 线段,让该线段执行旋转逻辑,其 end 坐标即是 p1’。

思路有了,下面来看一下代码的实现。前面实现的 绕起点旋转 封装到 _rotateByStart 方法中。

dart ---->[Line]---- void _rotateByStart(double rotate) { end = Offset( length * cos(rad + rotate), length * sin(rad + rotate), ) + start; }


外界可调用的的 rotate 方法,可以传入 centre 点,如果为空就以起点为旋转中心。下面 tag1tag2 出分别构建 p2p0p2p1 线段。之后两条线旋转即可获得我们期望的 p0’p1’ 坐标。

```dart double detaRotate = 0;

void rotate(double rotate, {Offset? centre}) { detaRotate = rotate - detaRotate; centre = centre ?? start; Line p2p0 = Line(start: centre, end: start); // tag1 Line p2p1 = Line(start: centre, end: end); // tag2 p2p0.rotateByStart(detaRotate); p2p1.rotateByStart(detaRotate); start = p2p0.end; end = p2p1.end; detaRotate = rotate; notifyListeners(); } ```


3.线段分度值出坐标

现在有个需求,计算线段 percent分率处点的坐标。比如 0.5 就线段中间的坐标,0.4 就是距离顶点长 40% 线长位置的坐标。效果如下:

| 0.2 | 0.5 | 0.8 | | ---- | ---- | ---- | | image-20210907085552225 | | |

其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率长度即可求出坐标值,代码实现如下:

dart Offset percent(double percent){ return Offset( length*percent*cos(rad), length*percent*sin(rad), )+start; }


前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转。本案例完整源码见: rotatebypoint


本文中的点线操作,都是对坐标本身的数据进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动 。另外,本文仅仅是两个点组成线 的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。

那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~


本文参见了 《掘金周边礼物》 的活动,大家可以在评论中积极讨论文章内容,留下你的思考与见解。最终会选取两个最优质的评论用户 , 每人赠送一枚 掘金徽章 ,截止时间为9 月 13 日中午 12 点前。欢迎大家积极讨论,避免不必要的灌水评论,谢谢支持~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值