Flutter 自绘组件:CustomCheckBox

134 阅读3分钟

Flutter 自带的CheckBox组件不能自由指定大小,可以通过自定义通过RenderObject来实现的一个可以自由指定大小的CustomCheckBox组件。
功能:
1、有选中和未选中状态。
2、状态切换时需要执行动画
3、可以自定义外观
CustomCheckBox定义:

class SSLCustomCheckBox extends LeafRenderObjectWidget{
  final double strokeWidth;//勾的线条宽度
  final Color strokeColor;
  final Color? fillColor;
  final bool value;
  final double radius;
  final ValueChanged<bool>? onChanged;
  const SSLCustomCheckBox({
    Key? key,
    this.strokeWidth = 2.0,
    this.value = false,
    this.strokeColor = Colors.white,
    this.fillColor = Colors.blue,
    this.onChanged,
    this.radius = 2.0,
  }):super(key: key);

  @override
  RenderObject createRenderObject(BuildContext context) {
    // TODO: implement createRenderObject
      return SSLRenderCustomCheckBox(
          value: value,
          strokeWidth: strokeWidth,
          strokeColor: strokeColor,
          fillColor: fillColor,
          onChanged: onChanged,
          radius: radius,
      );
  }
  @override
  void updateRenderObject(BuildContext context, covariant SSLRenderCustomCheckBox renderObject) {
    // TODO: implement updateRenderObject
    // super.updateRenderObject(context, renderObject);
    debugPrint("ssl chick checkbox 3\n");
    if (renderObject.value != value){
      //执行动画
      debugPrint("ssl chick checkbox 5\n");
      renderObject.animationStatus = value ? AnimationStatus.forward : AnimationStatus.reverse;
    }
    renderObject
    ..value = value
    ..strokeWidth = strokeWidth
    ..strokeColor = strokeColor
    ..fillColor = fillColor
    ..radius = radius
    ..onChanged = onChanged;
  }
}

注意:updateRenderObject方法中状态变化需要更新动画状态。

SSLRenderCustomCheckBox实现:

class SSLRenderCustomCheckBox extends RenderBox with SSLRenderObjectAnimationMixin{
  bool value;
  int? pointerId = -1;
  double? strokeWidth;
  Color? strokeColor;
  Color? fillColor;
  double radius;
  ValueChanged<bool>? onChanged;
  SSLRenderCustomCheckBox({
    Key? key,
    required this.value,
    this.strokeWidth,
    this.strokeColor,
    this.fillColor,
    required this.radius,
    this.onChanged,
    this.pointerId,
  }){
    progress = value ? 1 : 0;
  }

  @override
  // TODO: implement isRepaintBoundary
  bool get isRepaintBoundary => true;

//   //动画相关
//   double progress = 0;//动画当前进度
//   int? lastTimeStamp;//上一次绘制时间
//   //动画时长
//   Duration get duration => const Duration(milliseconds: 150);
// //动画当前状态
//   AnimationStatus animationStatus = AnimationStatus.completed;
//
//   set animationStatusValue(AnimationStatus v){
//     if (animationStatus != v){
//       markNeedsPaint();
//     }
//     animationStatus = v;
//   }
  //背景动画时长占比,背景动画要在前40%时间执行完,之后执行打勾动画
  final double bgAnimationInterval = .4;
  //布局
  @override
  void performLayout() {
    // TODO: implement performLayout
    // super.performLayout();
    //布局策略:如果父组件指定了宽高,则使用父组件宽高,否则宽高默认25。
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : const Size(25, 25),
    );
  }
  //绘制
  @override
  void doPaint(PaintingContext context, Offset offset) {
    // TODO: implement paint
    // super.paint(context, offset);
    Rect rect = offset & size;
    drawBackground(context, rect);
    drawCheckMark(context, rect);
  }
  //绘制背景
  void drawBackground(PaintingContext context, Rect rect){
      Color color = value ? (fillColor??Colors.grey) : Colors.grey;
      var paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = strokeWidth ?? 2.0
      ..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
    );
    final inner = RRect.fromRectXY(rectProgress!, 0, 0);
    context.canvas.drawDRRect(outer, inner, paint);
  }
  //绘制打勾
  void drawCheckMark(PaintingContext context, Rect rect){
    debugPrint("ssl chick checkbox 6 progress $progress bgAnimationIntervae $bgAnimationInterval\n");
      if (progress > bgAnimationInterval){
        debugPrint("ssl chick checkbox 7\n");
        //确定中间拐点位置
        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 lastOffset0 = 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(lastOffset0.dx, lastOffset0.dy);

        final paint = Paint()
        ..isAntiAlias = true
        ..style = PaintingStyle.stroke
        ..color = strokeColor ?? Colors.red
        ..strokeWidth = strokeWidth ?? 2.0;
        context.canvas.drawPath(path, paint);
      }
  }
  // //调度动画
  // void scheduleAnimation(){
  //   if (animationStatus != AnimationStatus.completed){
  //     SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
  //       if (lastTimeStamp != null){
  //         double delta = (timeStamp.inMicroseconds - lastTimeStamp!)/duration.inMicroseconds;
  //         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.inMicroseconds;
  //     });
  //   }else{
  //     lastTimeStamp = null;
  //   }
  // }
  //命中事件保持为true,表示可以响应事件
  @override
  bool hitTestSelf(Offset position) {
    // TODO: implement hitTestSelf
    return true;
  }

  @override
  void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
    // TODO: implement handleEvent
    // super.handleEvent(event, entry);

    if (event.down){
      debugPrint("ssl chick checkbox 1\n");
      pointerId = event.pointer;
    }else if (pointerId == event.pointer){
      //手指抬起触发回调
      onChanged?.call(!value);
      debugPrint("ssl chick checkbox 2\n");
    }
  }

}

其中performLayout布局策略是:如果父组件指定了固定宽高,则使用父组件指定的,否则使用默认宽高.

绘制CustomBox主要:

  1. 先绘制背景drawBackground
  2. 再绘制打勾drawCheckMark
  3. 动画调度SSLRenderObjectAnimationMixin
  4. 事件回调hitTestSelf&handleEvent

动画复用抽离:

mixin SSLRenderObjectAnimationMixin 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) {
    // TODO: implement paint
    // super.paint(context, offset);
    doPaint(context, offset);//调用子类绘制逻辑
    scheduleAnimation();
  }

  void scheduleAnimation(){
    debugPrint("ssl current progress$progress, animationStatus$_animationStatus");
    if (_animationStatus != AnimationStatus.completed){
      debugPrint("ssl current 11 progress$progress,");
      SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
        if (lastTimeStamp != null){
          double delta = (timeStamp.inMicroseconds - lastTimeStamp!)/duration.inMicroseconds;

//在特定情况下,可能在一帧中连续的往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.inMicroseconds;
      });
    }else{
      lastTimeStamp = null;
    }
  }
  //抽象子类实现绘制逻辑函数
  void doPaint(PaintingContext context, Offset offset);
}

测试示例:

class SSLCustomCheckBoxTest extends StatefulWidget{
  const SSLCustomCheckBoxTest({Key? key}):super(key: key);
  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return SSLCustomCheckBoxTestState();
  }
}

class SSLCustomCheckBoxTestState extends State<SSLCustomCheckBoxTest>{
  bool checked = false;
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Custom CheckBox"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SSLCustomCheckBox(
              value: checked,
              onChanged: onChangedAction,
            ),
            Padding(
                padding: const EdgeInsets.all(18.0),
                child: SizedBox(
                  width: 16,
                  height: 16,
                  child: SSLCustomCheckBox(
                    strokeWidth: 1,
                    radius: 1,
                    value: checked,
                    onChanged: onChangedAction,
                  ),
                ),
            ),
            SizedBox(
              width: 30,
              height: 30,
              child: SSLCustomCheckBox(
                strokeWidth: 3,
                radius: 3,
                value: checked,
                onChanged: onChangedAction,
              ),
            ),
          ],
        ),
      ),
    );
  }
  void onChangedAction(value){
    debugPrint("ssl chick checkbox 3\n");
    setState(() {
      checked = value;
    });
  }
}