【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类,再在其中实现动画状态管理、布局和绘制等逻辑。

相关推荐
️ 邪神4 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】启动页
android·flutter·ios·鸿蒙·reactnative
我码玄黄10 小时前
Flutter开发者进阶:接入安卓原生页面
android·前端·flutter
️ 邪神11 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】水平布局
flutter·ios·鸿蒙·reactnative·anroid
sunly_12 小时前
Flutter:InheritedWidget数据共享
android·javascript·flutter
陆业聪15 小时前
基本数据类型:Kotlin、Dart (Flutter)、Java 和 C++ 的比较
java·flutter·kotlin
️ 邪神1 天前
【Android、IOS、Flutter、鸿蒙、ReactNative 】标题栏
android·flutter·ios·鸿蒙·reatnative
Jewel1051 天前
Flutter代码混淆
android·flutter·ios
一头小火烧2 天前
flutter打包签名问题
flutter
sunly_2 天前
Flutter:异步多线程结合
flutter
AiFlutter2 天前
Flutter网络通信-封装Dio
flutter