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

7,271 阅读9分钟

前言

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

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


一、两点间的角度

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


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 点变化对应的绘制表现:

p1(60,60)p1(60,-80)p1(-60,-80)p1(-60,80)
image-20210902212856156image-20210902212949081
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 轴的上部分的角度是负值 ,如下面的 34 图所示。

p1(60,60)p1(-60,80)p1(-60,-60)p1(60,-80)
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 。另外通过 imagerect ,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象

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 的绘制逻辑。

---->[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!,
  ));
}

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

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 点进行处理,得到新的点。

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

2.解决方案和代码处理

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

求 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 点,如果为空就以起点为旋转中心。下面 tag1tag2 出分别构建 p2p0p2p1 线段。之后两条线旋转即可获得我们期望的 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% 线长位置的坐标。效果如下:

0.20.50.8
image-20210907085552225

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

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

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


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

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


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