前言
对一些有趣的绘制技能
和知识
, 我会通过 [番外篇]
的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”
和 “活力”
。另一方面,是为了让一些重要的知识有个 好的归宿
。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。
另外 [番外篇]
的文章是完全公开免费
的,也会同时在普通文章
中发表,且 [番外篇]
会在普通文章发布三日后入驻小册,这样便于错误的暴露
和收集建议反馈
。本文作为 [番外篇]
之一,主要来探讨一下角度
和坐标
的知识。
一、两点间的角度
你有没有想过,两点之间的角度如何计算。比如下面的 p0
和 p1
点间的角度,也就是两点之间的斜率
。这上过初中的人都知道,使用 反三角函数
算一下就行了。那其中有哪些坑点
要注意呢,下面一方面学知识,一方面练画技,一起画画吧!
1. 把线信息画出来
首先来画出如下效果,点 p0(0,0)
;点 p1(60,60)
。
为了方便数据管理,将起止点封装在 Line
类中。其中黑色部分的线体
由 Line
类承担,这样在就能减少画板的绘制逻辑。
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
点变化对应的绘制表现:
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
属性获取的角度特点。
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
图所示。
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
的处理。
---->[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
的坐标就很容易得出:
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
就可以根据先的角度绘制圆弧。
---->[AnglePainter#drawHelp]----
canvas.drawArc(
Rect.fromCenter(center: Offset.zero, width: 20, height: 20),
0,
line.positiveRad,
false,
helpPaint,
);
4. 点任意的绕点旋转
其实刚才的圆周运动是一个及其特殊
的情况,也就是线的起点在原点,且初始夹角为 0
。这样在坐标计算时,不必考虑初始角度的影响。但对于一般场合,上面的运算方式会出现错误。那如何实现 p0
点的任意呢?其实这就是移到简单的初中数学题:
已知: p0(a,b)、p1(c,d),求 p1 绕 p0 顺时针旋转 θ 弧度后得到 p1' 点。
求: p1' 点的坐标。
其实算起来很简单,如下,旋转了 θ
弧度后得到 p1'
。以 p0
为参考系原点的话,p1'
的坐标呼之欲出。
令两点间角度为 rad, 两点间距离为 length, 则:
p1': (length*cos(rad θ),length*sin(rad θ))
已知 p0 坐标为 start,则以 (0,0) 为坐标系,则
p1': (length*cos(rad θ),length*sin(rad θ)) start
由于 rotate
参数是总的旋转角度,而rotate
方法每次触发都会更新 end
的坐标,所以 rad
会不断更新,我们需要处理的是每次动画触发间的旋转角度,即下面的 detaRotate
。本案例完整源码见: rad_rotate
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
旋转。
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
,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象
。
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
的绘制逻辑。
---->[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
对象进行绘制即可。
---->[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
对象 附魔
。
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!,
));
}
同样,可以让线段绕起点进行旋转,如下的挥手动作。
代码语言:javascript复制void _updateLine() {
line.rotate(ctrl.value * 2* pi/50);
}
将背景图片进行绘制,就可以得到一个完整的效果。本案例完整源码见: body
三、线绕任意点旋转
下面我们来如何让已知线段按照某个点,进行旋转,这个问题等价于:
代码语言:javascript复制已知,p0、p1、p2点坐标,线段 p0、p1 绕 p2 顺时针旋转 θ 弧度后的到 p0'、p1'。
求:p0'、p1' 坐标。
1.问题分析
由于两点确定一条直线,线段 p0、p1
绕 p2
旋转,等价于 p0
和 p1
分别绕 p2
旋转。示意图如下:
对应于代码,就是在 rotate
方法中,传入一个坐标 centre
,根据该坐标和旋转角度,对 p0
和 p1
点进行处理,得到新的点。
void rotate(double rotate,{Offset? centre}) {
//TODO
}
2.解决方案和代码处理
之前已经处理了绕起点旋转的逻辑,这里我们可以用一个非常巧妙的方案:
代码语言:javascript复制求 p0’ 的坐标,可以构建 p2,p0 线段,让该线段执行旋转逻辑,其 end 坐标即是 p0’。
求 p1’ 的坐标,可以构建 p2,p1 线段,让该线段执行旋转逻辑,其 end 坐标即是 p1’。
思路有了,下面来看一下代码的实现。前面实现的 绕起点旋转
封装到 _rotateByStart
方法中。
---->[Line]----
void _rotateByStart(double rotate) {
end = Offset(
length * cos(rad rotate),
length * sin(rad rotate),
)
start;
}
外界可调用的的 rotate
方法,可以传入 centre
点,如果为空就以起点为旋转中心。下面 tag1
和 tag2
出分别构建 p2p0
和 p2p1
线段。之后两条线旋转即可获得我们期望的 p0’
和 p1’
坐标。
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%
线长位置的坐标。效果如下:
其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率
和长度
即可求出坐标值,代码实现如下:
Offset percent(double percent){
return Offset(
length*percent*cos(rad),
length*percent*sin(rad),
) start;
}
前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转
。本案例完整源码见: rotate_by_point
本文中的点线操作,都是对坐标本身的数据
进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动
。另外,本文仅仅是两个点组成线
的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。
那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~