likes
comments
collection
share

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

作者站长头像
站长
· 阅读数 53

往期回眸:

前面我们讲了路径、图形和图文的绘制,并且在最开始的时候讲了 Flutter 的动画体系。这一篇文章我们就把路径的绘制和动画结合起来,并且对基本的 API 进行解释。

动画的含义

动画其实一系列静态的图像按着时间线连起来。比如下面的静态图片:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

我们把静态图片按时间播放,就形成了下面的动图:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

Flutter 中动画的描述就是 Animation 类,这个类是动画框架实现的主力,大多数动画 Widget,包括我们自定义绘制的画笔,都接受一个 Animation 类型的对象作为其参数。想要动画的组件或者画笔就从这个对象中读取当前动画的值,然后响应这个值的变化。

前面我们知道了,AnimationController 可以通过 addListener 的方式实现动画值变化的监听,那我们就可以在方法体中调用 setState 来实现刷新。

示例代码如下:

class _PaintWidgetState extends State<PaintWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController animationController;
  late double offset;

  @override
  void initState() {
    super.initState();

    animationController = AnimationController(
      vsync: this,
      duration: Duration(seconds: 3),
    ); 
    animationController.addListener(() {
      setState(() {
        offset = animationController.value;//第一处
      });
    });
    animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.white,
      child: CustomPaint(
        painter: MyPainter(offset),//第二处
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  double value;

  MyPainter(this.value);//第二处

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.color = Colors.red;
    paint.strokeWidth = 2;
    canvas.drawLine(Offset(20, 60), Offset(20 + 100 * value, 60), paint);//第三处
  }

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

我们看上面的代码:

第一处:为动画增加了值变化的监听,并且监听的行为是 setState 刷新组件。 setState 的目的是调用 build 方法,用 offset 值构建新的 CustomPaint

第二处:将新的 offset 值传递进画笔,这个 offset 值就是动画值 0-1 的 double,代表了动画的进度

第三处:根据动画的进度来绘制一个直线,直线的起点是(20,60),端点是动画进度值的计算。

大家觉得上面可以动画么?对~,不可以。

为啥呢?因为 shouldRepaint 的返回值是 false,这个值表示了是否重新绘制。

因为我们每次是重新构建 CustomPaint 组件,但是其背后的 RenderCustomPaint 是不变的,RenderCustomPaint 会根据 shouldRepaint 的值来决定当 Widget 变化的时候是否发生绘制。

我们需要将 shouldRepaint 的返回值修改为 true

实际的运行效果就是:

shouldRepaint = trueshouldRepaint = false
Flutter 必知必会系列 —— 随心所欲的自定义绘制 IIIFlutter 必知必会系列 —— 随心所欲的自定义绘制 III

addListener + setState 的方式有什么优缺点吗?

优点

实现简单

缺点

效率较低,动画的实现依赖与 Widget 刷新联动的 RenderObject 的刷新

代码耦合,需要设置 shouldRepaint 是 true

除了这种手动设置联动的方式,Flutter 还提供了内置联动的方式

动画无非就是响应变化,动画数值变化的时候,画笔就去画画。Flutter 中提供了两种通知变化的机制:StreamListenable。动画用的就是 ListenableAnimationControllerListenable 的子类。

在画笔的构造方法中,画笔可以接受一个动画对象,如下图:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

上面是画笔的构造方法,一个 _repaint 属性就关联起来了动画和绘制两个体系,关联如下:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

RenderCustomPaintattach 方法中,为 _painter 增加了监听的响应。attachRenderObject 生命周期的第一个方法,所以相当于在声明的时候就增加了监听。 就是下面代码第一处的地方。

@override
void attach(PipelineOwner owner) {
  super.attach(owner);
  _painter?.addListener(markNeedsPaint);//第一处
  _foregroundPainter?.addListener(markNeedsPaint);
}

相当于 AnimationController 每次动画值变化的时候,都会调用到 RenderCustomPaintmarkNeedsPaint,而 markNeedsPaint 就是开始绘制的标记,注意它不是从布局开始,而是直接开始绘制。所以这样的效率更高。

_painteraddListener 实际就是 _repaint 的 addListener。也就是动画 AnimationController 的 addListener

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

上面就是 Flutter 中自定义绘制本身所支持绘制动画的机制

我们对 setState 的方式进行改造,如下:

class _PaintWidgetState extends State<PaintWidget>
    with SingleTickerProviderStateMixin {
 
  //...省略代码
  @override
  void initState() {
    super.initState();

    animationController = AnimationController(
      vsync: this,
      duration: Duration(seconds: 3),
    );
    //移出了addListener
    animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.white,
      child: CustomPaint(
        //第一处
        painter: MyPainter(animationController),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  Animation<double> animation;

  //第一处
  MyPainter(this.animation) : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    //...省略代码
    //第二处
    canvas.drawLine(
        Offset(20, 60), Offset(20 + 100 * animation.value, 60), paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    //第三处
    return false;
  }
}

我们看改造的地方

第一处:MyPainter 增加了动画的构造方法,最重要的是要通过 super 传递给CustomPainter 的 repaint

第二处:动画的值在 Animation 的 value 中,所以使用 animation.value 来取出动画值

第三处:即使 shouldRepaint 的值是 false,也会动画变化

下面我们介绍怎么给路径增加动画

绘制路径动画

绘制路径动画主要是两点:构造动画的路径确定动画量与路径量的映射

构造动画路径

我们知道路径是一系列的子路径组成。比如,一条 100 的直线,可以用一个点分割成两条 50 的直线,并且使用 path 的 xxTo 就可以实现路径追加的效果。

这样的话,我们就可以用若干点分割一条完成的函数曲线,并把函数曲线添加到一条路径上, 点越密集就越接近真实的曲线。

添加即可以用曲线的方式追加,也可以使用直线的方式追加,区别在于直线的方式可能会有所失真。

曲线的方式直线的方式
Flutter 必知必会系列 —— 随心所欲的自定义绘制 IIIFlutter 必知必会系列 —— 随心所欲的自定义绘制 III

我们以 ρ = 50 *(e^cosθ-2cos4θ+(sin(θ/12))^5 ) 图像为例,构建路径。

构建初始化坐标点

初始化的坐标点使用 Offset 表示,offset 是直角坐标系的描述,但是好多图像是极坐标的描述,所以需要一个转换的过程,这里简单说一下极坐标(高中知识已经还给老师了😂)。

极坐标 是指在平面内取一个定点 O叫极点,引一条射线 Ox,叫做极轴。对于平面内任何一点 M,用 ρ 表示线段 OM 的长度,θ 表示从 OxOM 的角度,ρ 叫做点 M 的极径,θ 叫做点 M 的极角,有序数对 (ρ,θ)就叫点 M 的极坐标,如下图:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

在极坐标下可以画出很多漂亮的图像:

极坐标下的圆漂亮的玫瑰线阿基米德线
Flutter 必知必会系列 —— 随心所欲的自定义绘制 IIIFlutter 必知必会系列 —— 随心所欲的自定义绘制 IIIFlutter 必知必会系列 —— 随心所欲的自定义绘制 III

对于设备来说,它不认识什么极坐标,它只知道 x 和 y,所以我们需要将极坐标转为设备可以认识的直角坐标。转换过程如下:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

找到 A 点的极坐标 (ρ,θ),将坐标直接带入下面的公示:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

比如圆的转换过程:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

所以我们只需要使用极坐标表示x和y即可。

void _initPoints() {
  points = [];
  for (int i = 0; i < 360; i++) {
    double thta = _convert(i);//第一处
    double p = _calY(thta);//第二处
    points.add(Offset(p * cos(thta), p * sin(thta)));//第三处
  }
}

double _calY(double thta) {
  return 50 * (pow(e, cos(x)) - 2 * cos(4 * x)) + pow(sin(x / 12), 5);//第二处
}

double _convert(int x) {
  return pi / 180 * x;
}

第一处:将角度值转为弧度制,因为极坐标是在角度的世界绘制的,所以需要先转换。

第二处:将角度转为极坐标的 ρ 值,注意一下,这里给原图像夸大了 50 倍,是为了让图像更大。

第三处:将极坐标转为了直角坐标,并添加到了坐标点集合中。注意一下,这里是从 0-360 度每个度数都添加一个坐标。

canvas.drawPoints(PointMode.points, points, paint);

绘制如下图:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

上面就是准备好了坐标点,下面将坐标点相连。

坐标点相连成路径

上面我们讲了连接的方式有曲线和直线

直线的方式

Path path = Path();
for (int i = 1; i < points.length; i++) {
  path.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(path, paint);

直接使用 lineTo 每一个点就可以。因为 Path 在 lineTo 到 坐标点 A 之后,下一段的 Path 的起点就是坐标点 A

lineTo 之后就连接成整个路径。效果如下:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

曲线的方式

Path path = Path();
path.moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
  double controllerX = (points[i].dx + points[i - 1].dx) / 2; //第一处
  double controllerY = (points[i].dy + points[i - 1].dy) / 2; //第一处

  path.quadraticBezierTo(
      controllerX, controllerY, points[i].dx, points[i].dy); //第二处
}
canvas.drawPath(path, paint);

我们使用二阶贝塞尔曲线的方式进行曲线连接,需要一个控制点和一个目标点

目标点是曲线上的每一个点 控制点是前一个目标点和后一个目标点的中间点

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

在坐标点密集的情况下,看不出来二者的区别,当坐标点少一半的时候就会看出来。

确定动画量与路径量的映射

上面已经有了路径 Path,现在就是把动画的时间与路径映射上。

映射之前我们先了解两个概念:路径度量 PathMetrics路径正切

路径度量就是路径的轮廓和片段。比如我们下面的代码:

Path.lineTo
Path.moveTo
Path.lineTo

就会生成了两个度量,因为中间的 moveTo 打断了一段路径。

使用 PathMetric 可以对路径进行计算,比如长度、提取路径片段等等。

路径提取

Path extractPath(double start, double end, {bool startWithMoveTo = true})

end 就是提取的终点

start 就是提取的起点

计算正切

通过正切可以获取路径的位置

Tangent? getTangentForOffset(double distance)

distance 就是路径与起点的偏移量

void paint(Canvas canvas, Size size) {
  //...省略代码
  //路径度量
  PathMetrics metrics = path.computeMetrics();
  metrics.forEach((element) {
    Path path = element.extractPath(0, element.length * animation.value);//第一处
    canvas.drawPath(path, paint);//第二处
  });
}

第一处的计算如下: animation.value 是动画的度量,比如进度 50% element.length 是路径的长度,比如总长度是 100 那么提取的路径是 0 到 50

第二处就是直接把子路径绘制下来

效果如下:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

上面的效果就是按时间进度把路径完整的绘制,除了上面的提取路径之外,我们还可以通过正切把路径的坐标点拿下来。

Tangent? tangent = element.getTangentForOffset(
    element.length * animation.value);//第一处
if (tangent != null) {
  paint.style = PaintingStyle.fill;
  paint.color = Colors.blue;
  canvas.drawCircle(tangent.position, 3, paint);//第二处
}

第一处就是拿到了动画进度下的正切点,如下红色的点:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

第二处的 position 就是实际的坐标点,在加上上面的绘制,效果如下:

Flutter 必知必会系列 —— 随心所欲的自定义绘制 III

注意看蓝色的小球球,带领着路径奔跑。

总结

动画的绘制就是一下两点:响应动画监听确定动画路径。动画的监听不需要使用 setState ,只需要将动画对象通过画笔的构造方法赋值给 repaint 成员变量,这种方式直接作用在绘制阶段,效率更高。让动画沿着路径走,只需要确定路径度量和路径正切,然后用动画的进度取出相应的子路径坐标点即可。

转载自:https://juejin.cn/post/7066707431971094542
评论
请登录