【Flutter学习笔记】10.6 自绘组件:CustomCheckbox

参考资料:《Flutter实战·第二版》 10.6 自绘组件:CustomCheckbox


Flutter自带的Checkbox组件是不能自由指定大小的,本节将通过自定义RenderObject的方式来实现一个复选框,有助于更深入地理解Flutter组件。 所要完成的复选框具有下面几个要求:

  1. 有选中和未选中两种状态。
  2. 状态切换时需要执行动画。
  3. 可以自定义外观。

CheckBox组件的定义如下,其中包含一些属性,并为其赋予默认值:

属性 含义 默认值
strokeWidth 对勾线条宽度 2.0
value 选中状态 false
strokeColor 对勾线条颜色 Colors.white
fillColor 填充颜色 Colors.blue(实际后面逻辑设置为当前context对应的主题色)
radius 圆角程度 2.0
onChanged 选中状态改变后的回调 null

要编写的CustomCheckbox组件先要继承LeafRenderObjectWidget类,其表示单个渲染对象的Widget,不具有子节点,符合复选框的特性。组件中需要复写两个函数createRenderObject()updateRenderObject()分别对应渲染对象创建的逻辑和更新逻辑,前者返回一个RenderCustomCheckbox,后者通过判断value的变化,设置渲染对象的动画状态、更新当前renderObject属性。当valuefalse变为true时,动画正向执行;反之当valuetrue变为false时,动画反向执行。

dart 复制代码
class CustomCheckbox extends LeafRenderObjectWidget {
  const CustomCheckbox({
    Key? key,
    this.strokeWidth = 2.0,
    this.value = false,
    this.strokeColor = Colors.white,
    this.fillColor = Colors.blue,
    this.radius = 2.0,
    this.onChanged,
  }) : super(key: key);

  final double strokeWidth; // "勾"的线条宽度
  final Color strokeColor; // "勾"的线条宽度
  final Color? fillColor; // 填充颜色
  final bool value; //选中状态
  final double radius; // 圆角
  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomCheckbox(
      strokeWidth,
      strokeColor,
      fillColor ?? Theme.of(context).primaryColor,
      value,
      radius,
      onChanged,
    );
  }

  @override
  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
    if (renderObject.value != value) {
      renderObject.animationStatus =
          value ? AnimationStatus.forward : AnimationStatus.reverse;
    }
    renderObject
      ..strokeWidth = strokeWidth
      ..strokeColor = strokeColor
      ..fillColor = fillColor ?? Theme.of(context).primaryColor
      ..radius = radius
      ..value = value
      ..onChanged = onChanged;
  }
}

接下来,进一步地实现RenderCustomCheckbox的逻辑,其继承了RenderBox类,包含动画管理、布局和绘制的过程:

dart 复制代码
class RenderCustomCheckbox extends RenderBox {
  bool value;
  int pointerId = -1;
  double strokeWidth;
  Color strokeColor;
  Color fillColor;
  double radius;
  ValueChanged<bool>? onChanged;

  // 下面的属性用于调度动画
  double progress = 0; // 动画当前进度
  int? _lastTimeStamp;//上一次绘制的时间
  //动画执行时长
  Duration get duration => const Duration(milliseconds: 150);
  //动画当前状态
  AnimationStatus _animationStatus = AnimationStatus.completed;
  set animationStatus(AnimationStatus v) {
    if (_animationStatus != v) {
      markNeedsPaint();
    }
    _animationStatus = v;
  }

  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
  final double bgAnimationInterval = .4;

  RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor,
      this.value, this.radius, this.onChanged)
      : progress = value ? 1 : 0;
  
  @override
  void performLayout() {}  //布局

  @override
  void paint(PaintingContext context, Offset offset) {
    Rect rect = offset & size;
    // 将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾'
    _drawBackground(context, rect);
    _drawCheckMark(context, rect);
    // 调度动画
    _scheduleAnimation();
  }
  
  // 画背景
  void _drawBackground(PaintingContext context, Rect rect) {}

  //画 "勾"
  void _drawCheckMark(PaintingContext context, Rect rect) { }
  //调度动画
  void _scheduleAnimation() {}

  ... //响应点击事件
}

实现布局算法

首先要能够自定义宽高,如果父组件指定了宽高,则采用父组件大小,否则默认宽高为25像素:

dart 复制代码
@override
void performLayout() {
  size = constraints.constrain(
    constraints.isTight ? Size.infinite : Size(25, 25),
  );
}

constraints这个变量并不是自己定义的,而是从父组件传递而来的,其为一个BoxConstraints对象,包含最大、最小宽高。如果约束是严格的(constraints.isTighttrue),那么 Size.infinite 会被用来尝试获取一个尽可能大的尺寸,但由于约束是严格的,最终的大小将会是 constraints 中指定的固定大小。如果约束不是严格的,那么会尝试使用 Size(25, 25) 作为大小,但最终的 size 仍然会受到 constraintsminWidthmaxWidthminHeightmaxHeight 的限制。

绘制CustomCheckbox

绘制UI的过程主要包括背景绘制和前景绘制两个部分。之前在设计RenderBox时有一个progress参数,用于标记当前动画进度,绘制过程与动画过程是紧密联系的。动画过程这里设计成两阶段的,前40%的时间绘制背景,后60%的时间绘制对勾。

绘制背景

首先当状态切换为true时,矩形会从边缘向中间收缩填充,填满为止;但状态切换为false时,则填充会从中间向边缘消散,直到边框为止。

实现思路是,首先绘制一个边框色的矩形区域,在里面绘制一个白色矩形,根据progress的值改变白色区域大小即可。Canvas API中已经有能够实现的方式,通过drawDRRect函数可以实现嵌套矩形的效果。

动画的整个过程中,需要通过插值Rect.lerp获得某个进度中的内部方块大小,其最后一个参数t表示起止状态之间的百分比,这里最大不超过bgAnimationInterval的100%,即progress超过0.4之后大小就不再变化,理论上不允许超过100%。正向动画时,中间块为最大白色块,即比填充色块小一个"边框宽",而且不带圆角:

但是原文中设计不好的一个地方就是里面的矩形没有圆角,其实应该根据外框的圆角值而改变的,这里圆角值设置为外框圆角的 <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α倍,其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> α = 外框高 − 外框粗细 外框高 × 0.8 \alpha=\frac{外框高 - 外框粗细}{外框高} \times 0.8 </math>α=外框高外框高−外框粗细×0.8,效果如下:

绘制前景

注意,在progress > 0.4时才能够开始画对勾。首先确定对勾中间拐点和第三个点的位置,这两个点的位置是要与外边框的宽高成比例的。中间点的计算公式: <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 矩形左 + 矩形宽 / 2.5 , 矩形下 − 矩形高 / 4 ) (矩形左 + 矩形宽/2.5, 矩形下 - 矩形高 / 4) </math>(矩形左+矩形宽/2.5,矩形下−矩形高/4),第三点计算公式: <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 矩形右 − 矩形宽 / 6 , 矩形上 + 矩形高 / 4 ) (矩形右 - 矩形宽/6, 矩形上 + 矩形高/4) </math>(矩形右−矩形宽/6,矩形上+矩形高/4)。这里遵循一个原则,画布的原点在左上角,x轴向右、y轴向下为正方向。

而后可通过progress的值对第三个点的位置进行插值,即:

dart 复制代码
final _lastOffset = Offset.lerp(
      secondOffset,
      lastOffset,
      (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
    )!;

其中计算的百分比同样是只对于打勾的动画来说的。最后,用Path将三个点连起来形成路径,就组成了对勾的样式。

实现动画

之前实现动画时,都会定义一个StatefulWidget存储动画状态,并通过setState触发更新。但是,这里我们直接继承的RenderObject类,是无法这样更新的UI的。这里有两种办法,第一是将自定义组件用一个StatefulWidget包裹,第二是通过自定义动画调度。

这部分动画实现的主要思路就是逐帧绘制 。自定义动画调度,是在一帧绘制结束后判断动画是否结束,动画未结束,则将当前组件标记为"需要重绘",那么在下一帧中,UI界面则会随之发生变化。首先,判断动画未结束时,在帧绘制结束的回调中,通过判断间隔来防止多次触发事件(短时间内连续添加回调,实际上都是属于同一帧结束,不需要处理),此时只标记重绘即可,无需改变progress的值。

正常情况下,通过当前绘制结束的时间与上次完成绘制的间隔来判断动画的进度,计算公式为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 当前时间 − 上次绘制结束时间 设置的动画执行时长 \frac{当前时间 - 上次绘制结束时间}{设置的动画执行时长} </math>设置的动画执行时长当前时间−上次绘制结束时间。正向动画时,增加进度,增加到1时结束;反向动画时,减少进度,到0时结束。需要注意,此时需要将进度值剪裁到正常范围内。

动画状态改变时,必须调用markNeedsPaint()执行第一次重绘才能进行后续的动画,否则第一次点击之后,虽然调用了updateRenderObject()改变了动画状态,但是并没有调用paint()对UI重绘,帧结束回调没有执行,因此点击了也没有任何变化,但此时value也是会随着点击事件而变化的:

dart 复制代码
// 设置动画状态
  set animationStatus(AnimationStatus v) {
    if (_animationStatus != v) {
      markNeedsPaint();
    }
    _animationStatus = v;
  }

最后,更新绘制完成时间并标记需要重绘。

响应点击事件

如果需要渲染对象处理事件,其必须能通过命中测试,之后才能在handleEvent方法中处理事件bool hitTestSelf(Offset position) => true;。当检测到按下手势PointerDown时,设置pointerId,手指抬起时(其它情况,原始指针事件包含按下、抬起、取消、移动)执行点击回调。这里执行回调时,带入的是!value,也就是在复选框内部管理状态了,随着点击而变化,而不是通过在回调内取反,这样的设计是较为合理的。

整体实现

RenderObject中的动画调度是较为复杂的,因此这里将其抽象成一个Minxin类,方便后续复用。

我们再整体梳理一遍该流程:

  • 首先,初始化时绘制UI,只有一个边框,此时动画状态为AnimationStatus.completed,Flutter分别执行createRenderObjectpaint()画出嵌套边框。
  • 随后,当点击复选框区域时,触发命中测试,调用handleEvent事件,并将value取反,此时updateRenderObject()执行,改变动画状态为AnimationStatus.forward,由此触发markNeedsPaint()进行重绘,调用paint(),继续调用doPaint()_scheduleAnimation()来加入新一帧开始的回调。
  • 当一帧绘制结束后,调用SchedulerBinding.instance.addPostFrameCallback()计算新的progress并请求下一帧的重绘,以此实现动画效果,其中需要注意必要的判断和动画状态的修改。

实现完整代码如下:

dart 复制代码
import 'dart:ui';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'TEAL WORLD'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(
          widget.title,
          style: TextStyle(
              color: Colors.teal.shade800, fontWeight: FontWeight.w900),
        ),
        actions: [
          ElevatedButton(
            child: const Icon(Icons.refresh),
            onPressed: () {
              setState(() {});
            },
          )
        ],
      ),
      body: const CheckBoxRoute(),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        tooltip: 'Increment',
        child: Icon(
          Icons.add_box,
          size: 30,
          color: Colors.teal[400],
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

class CheckBoxRoute extends StatefulWidget {
  const CheckBoxRoute({super.key});

  @override
  State<CheckBoxRoute> createState() => CheckBoxRouteState();
}

class CheckBoxRouteState extends State<CheckBoxRoute> {
  bool check = false;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox(
        width: 50,
        height: 50,
        child: CustomCheckbox(
          value: check,
          strokeWidth: 5,
          radius: 10,
          fillColor: Colors.teal,
          onChanged: (value) {
            setState(() {
              check = value;
            });
          },
        ),
      ),
    );
  }
}

class CustomCheckbox extends LeafRenderObjectWidget {
  const CustomCheckbox({
    Key? key,
    this.strokeWidth = 2.0,
    this.value = false,
    this.strokeColor = Colors.white,
    this.fillColor,
    this.radius = 2.0,
    this.onChanged,
  }) : super(key: key);

  final double strokeWidth; // "勾"的线条宽度
  final Color strokeColor; // "勾"的线条宽度
  final Color? fillColor; // 填充颜色
  final bool value; //选中状态
  final double radius; // 圆角
  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调

  @override
  RenderObject createRenderObject(BuildContext context) {
    print('createRenderObject');
    return RenderCustomCheckbox(
      strokeWidth,
      strokeColor,
      fillColor ?? Theme.of(context).primaryColor,
      value,
      radius,
      onChanged,
    );
  }

  @override
  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
    print('updateRenderObject');
    if (renderObject.value != value) {
      renderObject.animationStatus =
          value ? AnimationStatus.forward : AnimationStatus.reverse;
    }
    renderObject
      ..strokeWidth = strokeWidth
      ..strokeColor = strokeColor
      ..fillColor = fillColor ?? Theme.of(context).primaryColor
      ..radius = radius
      ..value = value
      ..onChanged = onChanged;
  }
}

class RenderCustomCheckbox extends RenderBox with RenderObjectAnimationMixin {
  bool value;
  int pointerId = -1;
  double strokeWidth;
  Color strokeColor;
  Color fillColor;
  double radius;
  ValueChanged<bool>? onChanged;

  RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor,
      this.value, this.radius, this.onChanged) {
    progress = value ? 1 : 0;
  }

  @override
  bool get isRepaintBoundary => true;

  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
  final double bgAnimationInterval = .4;

  @override
  void doPaint(PaintingContext context, Offset offset) {
    Rect rect = offset & size;
    _drawBackground(context, rect);
    _drawCheckMark(context, rect);
  }

  void _drawBackground(PaintingContext context, Rect rect) {
    Color color = fillColor;
    var paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill //填充
      ..strokeWidth
      ..color = color;

    // 我们对矩形做插值
    final outer = RRect.fromRectXY(rect, radius, radius);
    var rects = [
      rect.inflate(-strokeWidth),
      Rect.fromCenter(center: rect.center, width: 0, height: 0)
    ];
    var rectProgress = Rect.lerp(
      rects[0],
      rects[1],
      min(progress, bgAnimationInterval) / bgAnimationInterval,
    )!;

    double alpha = (size.width - strokeWidth) / size.width * 0.8;
    double radiusInner = alpha * radius;
    final inner = RRect.fromRectXY(rectProgress, radiusInner, radiusInner);
    // 画背景
    context.canvas.drawDRRect(outer, inner, paint);
  }

  //画 "勾"
  void _drawCheckMark(PaintingContext context, Rect rect) {
    // 在画好背景后再画前景
    if (progress > bgAnimationInterval) {
      //确定中间拐点位置
      final secondOffset = Offset(
        rect.left + rect.width / 2.5,
        rect.bottom - rect.height / 4,
      );
      // 第三个点的位置
      final lastOffset = Offset(
        rect.right - rect.width / 6,
        rect.top + rect.height / 4,
      );

      // 我们只对第三个点的位置做插值
      final _lastOffset = Offset.lerp(
        secondOffset,
        lastOffset,
        (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
      )!;

      // 将三个点连起来
      final path = Path()
        ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2)
        ..lineTo(secondOffset.dx, secondOffset.dy)
        ..lineTo(_lastOffset.dx, _lastOffset.dy);

      final paint = Paint()
        ..isAntiAlias = true
        ..style = PaintingStyle.stroke
        ..color = strokeColor
        ..strokeWidth = strokeWidth;

      context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
    }
  }

  @override
  void performLayout() {
    // 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : const Size(25, 25),
    );
  }

  // 必须置为true,否则不可以响应事件
  @override
  bool hitTestSelf(Offset position) => true;

  // 只有通过点击测试的组件才会调用本方法
  @override
  void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
    if (event.down) {
      pointerId = event.pointer;
    } else if (pointerId == event.pointer) {
      // 判断手指抬起时是在组件范围内的话才触发onChange
      if (size.contains(event.localPosition)) {
        onChanged?.call(!value);
      }
    }
  }
}

mixin RenderObjectAnimationMixin on RenderObject {
  double _progress = 0;
  int? _lastTimeStamp;

  // 动画时长,子类可以重写
  Duration get duration => const Duration(milliseconds: 200);
  AnimationStatus _animationStatus = AnimationStatus.completed;
  // 设置动画状态
  set animationStatus(AnimationStatus v) {
    if (_animationStatus != v) {
      markNeedsPaint();
    }
    _animationStatus = v;
  }

  double get progress => _progress;
  set progress(double v) {
    _progress = v.clamp(0, 1);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    print("paint()");
    doPaint(context, offset); // 调用子类绘制逻辑
    _scheduleAnimation();
  }

  void _scheduleAnimation() {
    if (_animationStatus != AnimationStatus.completed) {
      SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
        if (_lastTimeStamp != null) {
          double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) /
              duration.inMilliseconds;

          //在特定情况下,可能在一帧中连续的往frameCallback中添加了多次,导致两次回调时间间隔为0,
          //这种情况下应该继续请求重绘。
          if (delta == 0) {
            markNeedsPaint();
            return;
          }

          if (_animationStatus == AnimationStatus.reverse) {
            delta = -delta;
          }
          _progress = _progress + delta;
          if (_progress >= 1 || _progress <= 0) {
            _animationStatus = AnimationStatus.completed;
            _progress = _progress.clamp(0, 1);
          }
        }
        markNeedsPaint();
        _lastTimeStamp = timeStamp.inMilliseconds;
      });
    } else {
      _lastTimeStamp = null;
    }
  }

  // 子类实现绘制逻辑的地方
  void doPaint(PaintingContext context, Offset offset);
}

实现最终效果如下:

综上,通过RenderObject自定义Widget,首先继承特定的RenderObject类管理对象创建和更新的逻辑,这里返回的是一个RenderBox类,再在其中实现动画状态管理、布局和绘制等逻辑。

相关推荐
江上清风山间明月1 天前
Flutter开发的应用页面非常多时如何高效管理路由
android·flutter·路由·页面管理·routes·ongenerateroute
Zsnoin能2 天前
flutter国际化、主题配置、视频播放器UI、扫码功能、水波纹问题
flutter
早起的年轻人2 天前
Flutter CupertinoNavigationBar iOS 风格导航栏的组件
flutter·ios
HappyAcmen2 天前
关于Flutter前端面试题及其答案解析
前端·flutter
coooliang2 天前
Flutter 中的单例模式
javascript·flutter·单例模式
coooliang2 天前
Flutter项目中设置安卓启动页
android·flutter
JIngles1232 天前
flutter将utf-8编码的字节序列转换为中英文字符串
java·javascript·flutter
B.-2 天前
在 Flutter 中实现文件读写
开发语言·学习·flutter·android studio·xcode
freflying11192 天前
使用jenkins构建Android+Flutter项目依赖自动升级带来兼容性问题及Jenkins构建速度慢问题解决
android·flutter·jenkins
机器瓦力3 天前
Flutter应用开发:对象存储管理图片
flutter