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

1,023 阅读5分钟

这是我参与2022首次更文挑战的第16天,活动详情查看:2022首次更文挑战

往期回眸:

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

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

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

前面三篇文章基本就把 Flutter 的自定义绘制介绍完了,通过这些 API 开发者可以绘制出完全自定义的内容,这些自定义的内容可能是很简单的,也可能是很复杂的。

这一篇就是前面的总结。

用谁画

自定义的绘制需要借助 Flutter 提供的组件 —— CustomPaint 组件。CustomPaint 接受下面几个比较重要的参数

参数名参数类型参数作用
keyKey?和 Widget 的 key 作用一样
painterCustomPainter?背景图的画笔
foregroundPainterCustomPainter?前景图的画笔
sizeSize组件的尺寸
childWidget?子 Widget

上面的参数有两个注意点,如下:

绘制内容的层级上:自底向上依次是 painter ——> child ——> foregroundPainter,上面的内容会把下面的内容覆盖。

组件的尺寸:如果设置了 child,那么就用 child 的尺寸,否则就用 size 的尺寸,size 的尺寸默认是 Size.zero

绘制的内容会产生遮盖,比如下面的代码:

CustomPaint(
    painter: MyPainter(false),
    child: Container(
      height: 200,
      width: 200,
      color: Colors.blue,
    ),
    foregroundPainter: MyPainter(true),
  )
  
class MyPainter extends CustomPainter {
  bool isForeground;

  MyPainter(this.isForeground);

  @override
  void paint(Canvas canvas, Size size) {
    if (isForeground) {
      canvas.clipRect(Rect.fromLTWH(0, 0, 100, 100));
    } else {
      canvas.clipRect(Rect.fromLTWH(0, 0, 300, 300));
    }
    Paint paint = Paint();
    paint.color = isForeground ? Colors.red : Colors.pinkAccent;
    paint.strokeWidth = 2;
    paint.style = PaintingStyle.stroke;
    canvas.drawPaint(paint);
  }

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

效果如下:

层序遮盖.png

粉色的就是背景画笔的内容,蓝色的是 child 的内容,红色的就是前景画笔的内容。

注意 size 并不会真正的影响绘制边界,比如下面的代码:

Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      CustomPaint(
        painter: MyPainter(),
      ),
      Text("文本")
    ],
  )
  
class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.color = Colors.red;
    paint.strokeWidth = 2;
    paint.style = PaintingStyle.stroke;
    canvas.drawLine(Offset.zero, Offset(200, 200), paint);
  }
}

CustomPaint 的尺寸是 0,依然会画出一条 (0,0)(200, 200) 的红色斜线。所以官方文档有一句话:To enforce painting within those bounds, consider wrapping this CustomPaint with a ClipRect widget.
使用 ClipRect 包裹 CustomPaint 来强制 CustomPaint 是其本身的大小。

绘制的内容

绘制的内容可能是很简单的表格,也能是很复杂的图文。虽然自定义绘制有很强的灵活性,但是也需要很高的投入成本。
比如像一张具有颜色混合的图片,开发者费劲半天还不如UI直接切一张图呢~~。
所以绘制这一块,还是具体问题具体分析。

基本的绘制元

基本的绘制元包含了:点、线、矩形、多边形、图文等的绘制。

方法名作用
drawLine绘制直线
drawRect绘制矩形
drawRRect绘制圆角矩形
drawDRRect绘制圆角矩形环
drawOval绘制椭圆
drawCircle绘制圆形
drawArc绘制圆弧
drawImage绘制图片
drawImageRect绘制带有位置信息的部分图片
drawImageNine绘制.9图片
drawParagraph绘制文本
drawPoints绘制点

除了这些基本的绘制,还可以借助路径绘制实现自定义的效果。路径绘制也是由基本的点线面组成,不同的是,一条路径上可以拼接多个子路径。比如:

Path path = Path();
path.lineTo(20, 20);
path.moveTo(30,30);
path.lineTo(40, 40);

上面代码的路径会有两条子路径。

不管是基本的绘制还是路径的绘制,Flutter 只认识直角坐标下(x,y),而我们绘制的图形可能是极坐标描述的,这个时候需要进行角度的转换与坐标的转换。
比如: 漂亮的 ρ = 50 *(e^cosθ - 2cos4θ + (sin(θ/12))^5 ) 图像对应坐标生成代码如下:

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;
}

除了上面基本的图形绘制,自定义的绘制还需要手势、动画的支持。

响应手势的绘制

CustomPaint 背后的 RenderCustomPaint 虽有手势竞技场的检测,如下代码:

@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  if (_foregroundPainter != null && (_foregroundPainter!.hitTest(position) ?? false))
    return true;
  return super.hitTestChildren(result, position: position);
}

@override
bool hitTestSelf(Offset position) {
  return _painter != null && (_painter!.hitTest(position) ?? true);
}
  

但是没有具体的响应处理逻辑,所以想要增加手势效果,需要在自定义的Widget 上面包裹一层手势组件 —— GestureDetector。如下的代码:

class _PaintWidgetState extends State<PaintWidget>
    with SingleTickerProviderStateMixin {
 
  late ValueNotifier<Offset> valueNotifier;
  @override
  void initState() {
    super.initState();
    valueNotifier = ValueNotifier(Offset.zero);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
            onTap: (){},
            onPanStart: (detail){},
            onPanUpdate: (detail){
              valueNotifier.value = detail.globalPosition;
            },
            child: CustomPaint(
              painter: MyPainter(valueNotifier),
            ),
      );
  }
}

class MyPainter extends CustomPainter {
  ValueNotifier<Offset> value;
  MyPainter(this.value):super(repaint: value);

  @override
  void paint(Canvas canvas, Size size) {
   
  }
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

在手势的回调中,通过画笔中构造参数 repaint 的通知机制,进行手势事件的传递。
上面的代码中,只要触摸就会触发画笔的重绘制,达到手势响应的效果。

响应动画的绘制

除了静态的画面,实际的需求中也有动画,比如点击柱状图产生渐宽效果,进度条的渐进等效果。

动画的驱动可以通过我们熟知的 AnimationController 完成,和手势响应相似,画笔与动画的关联也是通过画笔构造参数的 repaint,响应的流程如下:

image

当然了,除了机制上的支持,动画最重要的还是动画的路径是什么? 通过路径的API我们可以拿到动画进度下的某一段路径是什么。

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

end 就是动画进度与路径总长度的乘积

路径的长度在 PathMetric 度量中,结合两者就可以实现动画与路径的映射。

除此之外,动画的位置坐标点可以通过正切拿到。如下代码:

Tangent? tangent = element.getTangentForOffset(
    element.length * animation.value);

绘制的色彩

绘制的色彩比较麻烦,需要知道颜色混合、遮罩、模糊等。 大家可以通过设置画笔的属性实现。

属性类型作用
colorColor画笔颜色
colorFilterColorFilter?颜色过滤器,设置颜色混合模式、颜色矩阵
imageFilterImageFilter?图片过滤器,设置图片形状、高斯模糊
invertColorsbool是否颜色反转
shaderShader着色器

通过这些色彩的设置,我们可以实现意想不到的效果。

着色器系列

linearsweeplinearradial
tile_mode_clamp_linear.pngtile_mode_clamp_sweep.pngtile_mode_mirror_linear.pngtile_mode_repeated_radial.png

颜色混合系列

image

总结

通过这四篇文章,自定义绘制算是告一段落,绘制的内容可能简单也可能复杂,可能需要数学,也可能需要色彩。笔和纸告诉了我们,接下来就等大家发挥咯~。

目前已经完成了三棵树 👉Flutter 必知必会系列 —— 三棵树最终章 和 自定义绘制的阅读记录,下个系列开始路由吧~~