前言
对一些有趣的绘制技能
和知识
, 我会通过 [番外篇]
的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”
和 “活力”
。另一方面,是为了让一些重要的知识有个 好的归宿
。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。
另外 [番外篇]
的文章是完全公开免费
的,也会同时在普通文章
中发表,且 [番外篇]
会在普通文章发布三日后入驻小册,这样便于错误的暴露
和收集建议反馈
。本文作为 [番外篇]
之一,主要来探讨一下角度
和坐标
的知识。
一、两点间的角度
你有没有想过,两点之间的角度如何计算。比如下面的 p0
和 p1
点间的角度,也就是两点之间的斜率
。这上过初中的人都知道,使用 反三角函数
算一下就行了。那其中有哪些坑点
要注意呢,下面一方面学知识,一方面练画技,一起画画吧!
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) | | ------ | ------- | ------ | ---- | | | | | |
```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 轴的上部分的角度是负值
,如下面的 3
、4
图所示。
| 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
。另外通过 image
和 rect
,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象
。
```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
方法,通过 canvas
和 line
进行图片的绘制。这样方便在 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、p1
绕 p2
旋转,等价于 p0
和 p1
分别绕 p2
旋转。示意图如下:
对应于代码,就是在 rotate
方法中,传入一个坐标 centre
,根据该坐标和旋转角度,对 p0
和 p1
点进行处理,得到新的点。
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
点,如果为空就以起点为旋转中心。下面 tag1
和 tag2
出分别构建 p2p0
和 p2p1
线段。之后两条线旋转即可获得我们期望的 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 | | ---- | ---- | ---- | | | | |
其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率
和长度
即可求出坐标值,代码实现如下:
dart Offset percent(double percent){ return Offset( length*percent*cos(rad), length*percent*sin(rad), )+start; }
前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转
。本案例完整源码见: rotatebypoint
本文中的点线操作,都是对坐标本身的数据
进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动
。另外,本文仅仅是两个点组成线
的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。
那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~
本文参见了 《掘金周边礼物》 的活动,大家可以在评论中积极讨论文章内容,留下你的思考与见解。最终会选取两个最优质的评论用户
, 每人赠送一枚 掘金徽章
,截止时间为9 月 13 日中午 12 点前
。欢迎大家积极讨论,避免不必要的灌水评论,谢谢支持~