阅读 393

Flutter自定义View的实现

上一篇中(Flutter自定义Banner的实现)banner的indicator实现就是采取了自定义view的形式。在这篇文章中我们来重点介绍一下自定义view是如何实现的。在Android中如果要自定义view的话,需要继承View,在他的onDraw方法中用画笔(paint)在画布(canvas)上绘制相应的内容,如果要触发重绘的话只需调用invalidate即可。同样在这里我们需要继承CustomPainter,在它的paint方法中来绘制相应的内容,通过shouldRepaint的返回值来判断是否需要重绘。接下来我们就详细介绍一下它的使用,本篇的内容会以下图的内容作为示例

device-2021-05-31-181914.gif

CustomPainter是作为CustomPaint的子Widget来使用的。那么就首先来看一下CustomPaint的构造方法

const CustomPaint({
  Key? key,
  this.painter,
  this.foregroundPainter,
  this.size = Size.zero,
  this.isComplex = false,
  this.willChange = false,
  Widget? child,
})
复制代码

这里的painter以及foregroundPainter都是CustomPainter,他们分别表示child的背景和前景,而Size则表示大小。注意如果同时指定了child和Size那么最终的大小以child的尺寸为准。对于自定义view我们可以分为以下几步:

  • 创建继承CustomPainter的类。比如这里的class ProgressRingPainter extends CustomPainter
  • 在类中的paint方法绘制想要的内容:在这个方法中会得到画布canvas以及控件的尺寸Size,我们只需要利用画笔(paint)在画布(canvas)绘制相应的内容即可。
  • 在类中的shouldRepaint方法返回需要重绘的条件:如果返回true则会在相应的值变化时会触发重绘。
  • 如果需要做动画那么还需要在自定义类的构造函数中添加:super(repaint:xxx)就能自动的添加监听器。这样当值变化时就能自动的触发重绘。具体原因如下
    • 类CustomPainter结构如下:可以看到如果我们添加了super方法其实是为这里的repaint赋值。它是一个listenable对象

image.png

- 我们在CustomPaint中指定painter时,在创建这个CustomPaint时会调用painter的set方法。
复制代码

image.png

- 这里主要是_didUpdatePainter方法
复制代码

image.png

-  调用了addListenter方法,这个addListener方法就是CustomPainter类中定义的,可以看到我们用刚刚传进去的repaint对象添加了监听器。所以我们可以在CustomPainter中如此更新页面。   
复制代码

原理我们就先介绍到这儿。接下来我们在介绍一些基础知识,其实自定义view可以总结为在画布上用画笔画出想要的图形。所以又三个关键字画笔画布图形

  1. 画笔:Paint,这个是我们必须用到的。常用的属性有color(设置画笔颜色),style(描边还是填充对应于PaintingStyle.stroke及PaintingStyle.fill),strokeWidth(描边的宽度),isAntiAlias(是否抗锯齿),shader(绘制渐变色常用的LinearGradient线性渐变,SweepGradient扫描渐变,RadialGradient辐射式渐变)。这里就不在深入的举例了,如果感兴趣的话可以把这些属性设置给画笔就可以看到效果。

  2. 画布:Canvas,利用画笔在画布上绘制内容其实就是利用canvas.drawxxx(xxx,paint)来实现的)这里的xxx其实就是下图中的内容

    image.png

    比如这里的drawPath则是按照指定的路径Path绘制,drawCircle则是绘制圆形,drawLine则是绘制直线等等。画布不仅仅可以绘制,他还可以变换和裁剪。比如常用的平移canvas.translate,旋转canvas.rotate,裁剪canvas.clipRect。为了确保所画的内容不超过控件大小通常在绘制开始时会剪裁画布的大小,让画布的大小为设置的Size,然后将坐标系原点移动到view的中间(系统坐标系是在左上角,向右及向下分别为x及y轴正向)。

    image.png

  3. 图形:Path,这里叫做图形其实并不准确,通过上面我们可以知道通过canvas我们可以直接画圆形,方形,点,线等。但如果比较复杂的比如画贝赛尔曲线,这就需要Path,所以这里重点介绍一下Path。canvas.drawxxx实现的图形Path都可以实现。比如canvas.drawLine则可以通过path.lineTo实现,canvas.drawCircle则可以通过path.addOval来实现。对于Path还有很多自己的操作

    • close:将首尾连接形成闭合路径(path.close())
    • reset:重置路径,清空内容(path.reset())
    • shift:路径平移,且返回一条新的路径,比如画布上只有一个path是一个三角形,执行path.shift(40,0)的话就是在原来三角形右侧40的地方再画一个一模一样的三角形,整个画布上到目前为止有两个三角形
    • contains:判断某个点是否在path中,可以用来做触点判断和碰撞检测(path.contains(Offset(20, 20))
    • getBounds:当前path所在的矩形区域,返回的是一个Rect。(Rect bounds = path.getBounds();)
    • transform:路径变换。对于对称性图案,当已经有一部分单体路径,可以根据一个4*4的矩阵对路径进行变换。可以使用Matrix4对象进行辅助生成矩阵。能很方便进行旋转、平移、缩放、斜切等变换效果。
    • combine:路径联合,可用于复杂路径的生成。canvas.drawPath(Path.combine(PathOperation.xor, path1, path2), paint);这里需要注意的其实就是PathOperation的值。xor则是绘制path1与path2但不绘制二者重合的部分;difference则是仅绘制path1而且与path2重合的内容不绘制;reverseDifference则是仅绘制path2而且与path1重合的部分不绘制;intersect则是绘制path1与path2的交集,union则是绘制path1与path2的并集
    • computeMetrics:通过path.computeMetrics(),可以获取一个可迭代PathMetrics类对象 它迭代出的是PathMetric对象,也就是每个路径的测量信息。也就是说通过path.computeMetrics()你获得是一组路径的测量信息。这些信息包括路径长度 length、路径索引 contourIndex 及 isClosed路径是否闭合isClosed。
    • 根据测量的路径获取位置信息:比如我想要在路径一半的地方绘制一个小球,如果通过自己计算的话,非常困难。幸运的是通过路径测量,实现起来就非常方便。甚至还能得到改点的角度、速度信息。下面通过pm.length * 0.5表示在路径长度50%的点的信息。pm.getTangentForOffset则是获取在路径上某处的正切值,通过这个正切值我们可以拿到改点的坐标以及角度速度信息。比如做按轨迹运动的动画,就可以按动画进度(progress)更新路径上点的位置就行,也就是下面的pm.length*progress。
    • 根据动画的进度来绘制轨迹:这个就用到了PathMetrics中的extractPath方法,Path extractPath(double start, double end, {bool startWithMoveTo = true})

    至此准备工作已经完成。接下来真正开始绘制我们的进度条。首先我们分析一下

  • 最外层有一个灰色的圆环,这个比较好实现其实就是画笔采用描边的方式画一个圆形,圆环的宽度就是描边的宽度。所以这里需要一个画笔paint,而且他填充方式为stroke
  • 外层则是随着进度的不断增加逐渐绘制的粉红色圆环,本质上也是圆环,只是绘制的多少问题。这里是按照动画的进度或者说是我们给定的进度来绘制相应的长度。所以这里就用到了我们上面所说的路径的测量与截取,所以这里需要一个PathMetric来截取当前的路径一点一点绘制,这个一点一点就是进度,通过开篇的分析可以知道这里需要一个Listenable对象,这里我们采用Animation<double>,当然也可以采用ValueNotifier等。
  • 剩下的就是文字的绘制,这里我们采用CustomPaint的child实现(为了简化自定义view的绘制以及理解CustomPaint的child属性)。

按照上面说的自定义view步骤我们创建ProgressRingPainter类让其继承CustomPainter。并定义我们需要的参数(当然这里的底色,进度条颜色,圆环宽度等都应该抽成变量支持配置,这里就偷个懒)

class ProgressRingPainter extends CustomPainter {
  //画笔
  Paint _paint;
  //进度
  final Animation<double> progress;
  //路径测量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
   ...
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
   ...
  }
}
复制代码

可以看到在构造方法中添加了super:(repaint:progress),也就是说在这个值发生变化时会尝试重绘。在构造方法中我们通过path.addOval方式来绘制圆形并且对该条路径进行测量得到PathMetric对象。 接下来我们先实现触发重绘的条件,其实就是上一次的progress和现在的不一致就行

@override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
复制代码

接下来才是重要的绘制方法paint,直接上代码

@override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);

    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }
复制代码

我们按照之前说的首先将画布裁剪为大小为Size的矩形,防止绘制超出范围(如果不裁剪绘制超出Size大小也会显示感兴趣的可以试一下),这里不仅仅可以剪裁为矩形也可以使用clipRRect剪裁为圆角矩形,也可以使用CilpPath按特定形状剪裁。然后我们将坐标系原点移动到view的中心;接着绘制了一个静态的圆环,最后通过drawPath来绘制可变的进度,当然这里获取了整个路径的长度,通过百分比来绘制。对于可变的圆环我们就绘制完毕,完整代码如下

class ProgressRingPainter extends CustomPainter {
  //画笔
  Paint _paint;
  //进度
  final Animation<double> progress;
  //路径测量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);
    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
}

复制代码

剩下就是文字部分,随着进度的不断变化,文字会不断的重绘,想达到整个效果要么是整个StatefullWidget调用setState重建,要么就是文字控件自身刷新,我们采用后者。因为更新进度的Animation(abstract class Animation<T> extends Listenable implements ValueListenable<T> )他继承了Listenable,只要接收他的通知就可以重建view。这里我们采用ValueListenableBuilder来创建这个Text

class ValueListenableBuilder<T> extends StatefulWidget {
  /// Creates a [ValueListenableBuilder].
  ///
  /// The [valueListenable] and [builder] arguments must not be null.
  /// The [child] is optional but is good practice to use if part of the widget
  /// subtree does not depend on the value of the [valueListenable].
  const ValueListenableBuilder({
    Key? key,
    required this.valueListenable,
    required this.builder,
    this.child,
  }) : assert(valueListenable != null),
       assert(builder != null),
       super(key: key);

  /// The [ValueListenable] whose value you depend on in order to build.
  ///
  /// This widget does not ensure that the [ValueListenable]'s value is not
  /// null, therefore your [builder] may need to handle null values.
  ///
  /// This [ValueListenable] itself must not be null.
  final ValueListenable<T> valueListenable;
      ....
  }
复制代码

可以看到他有两个必要的参数,valueListenable则是传入可变化的变量这里正好是动画的进度,builder则是根据这个变化的值来构建新的widget,builder的实现如下

Widget buildText(BuildContext context, double value, Widget child) {
    return Text("${(value * 100).toInt()}%");
  }
复制代码

至此各部分的实现都已完成,所以使用的完整代码如下

class ProgressRing extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _StateProgressRing();
}

class _StateProgressRing extends State<ProgressRing>
    with SingleTickerProviderStateMixin {
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 10),
    )..repeat(reverse: true);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPaint'),
      ),
      body: Center(
        child: CustomPaint(
          size: Size(100, 100),
          painter: ProgressRingPainter(controller),
          child: Container(
            width: 100,
            height: 100,
            child: Center(
              child: ValueListenableBuilder(
                valueListenable: controller,
                builder: buildText,
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();

    super.dispose();
  }

  Widget buildText(BuildContext context, double value, Widget child) {
    return Text("${(value * 100).toInt()}%");
  }
}

class ProgressRingPainter extends CustomPainter {
  //画笔
  Paint _paint;
  //进度
  final Animation<double> progress;
  //路径测量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);

    // canvas.rotate(-pi / 2);

    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
}

复制代码

自定义view已实现,还请大家批评指正。

文章分类
前端
文章标签