跳到正文
Willh的个人博客 记录 · 思考 · 分享
《Flutter实战·第二版》

10.6 自绘组件:CustomCheckbox

Flutter 自带的 Checkbox 组件是不能自由指定大小的,本节我们通过自定义一个可以自由指定大小的 CustomCheckbox 组件来演示如何通过定义 RenderObject 的方式来自定义组件(而不是通过组合)。

CustomCheckbox

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

CustomCheckbox 定义如下:

1class CustomCheckbox extends LeafRenderObjectWidget {
2  CustomCheckbox({
3    Key? key,
4    this.strokeWidth = 2.0,
5    this.value = false,
6    this.strokeColor = Colors.white,
7    this.fillColor = Colors.blue,
8    this.radius = 2.0,
9    this.onChanged,
10  }) : super(key: key);
11
12  final double strokeWidth; // “勾”的线条宽度
13  final Color strokeColor; // “勾”的线条宽度
14  final Color? fillColor; // 背景填充颜色
15  final bool value; //选中状态
16  final double radius; // 圆角
17  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调
18
19  
20  RenderObject createRenderObject(BuildContext context) {
21    return RenderCustomCheckbox(
22      strokeWidth,
23      strokeColor,
24      fillColor ?? Theme.of(context).primaryColor, // 填充颜色如果未指定则使用主题色
25      value,
26      radius,
27      onChanged,
28    );
29  }
30
31  
32  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
33    if (renderObject.value != value) {
34      //选中状态发生了变化,则需要调整动画状态以执行过渡动画
35      renderObject.animationStatus =
36          value ? AnimationStatus.forward : AnimationStatus.reverse;
37    }
38    renderObject
39      ..strokeWidth = strokeWidth
40      ..strokeColor = strokeColor
41      ..fillColor = fillColor ?? Theme.of(context).primaryColor
42      ..radius = radius
43      ..value = value
44      ..onChanged = onChanged;
45  }
46}

上面代码中唯一需要注意的就是 updateRenderObject 方法中当选中状态发生变化时,我们要更新RenderObject中的动画状态,具体逻辑是:当从未选中切换为选中状态时,执行正向动画;当从选中状态切换为未选中状态时执行反向动画。

接下来需要实现 RenderCustomCheckbox:

1class RenderCustomCheckbox extends RenderBox {
2  bool value;
3  int pointerId = -1;
4  double strokeWidth;
5  Color strokeColor;
6  Color fillColor;
7  double radius;
8  ValueChanged<bool>? onChanged;
9
10  // 下面的属性用于调度动画
11  double progress = 0; // 动画当前进度
12  int? _lastTimeStamp;//上一次绘制的时间
13  //动画执行时长
14  Duration get duration => const Duration(milliseconds: 150);
15  //动画当前状态
16  AnimationStatus _animationStatus = AnimationStatus.completed;
17  set animationStatus(AnimationStatus v) {
18    if (_animationStatus != v) {
19      markNeedsPaint();
20    }
21    _animationStatus = v;
22  }
23
24  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
25  final double bgAnimationInterval = .4;
26
27  RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor,
28      this.value, this.radius, this.onChanged)
29      : progress = value ? 1 : 0;
30  
31  
32  void performLayout() {}  //布局
33
34  
35  void paint(PaintingContext context, Offset offset) {
36    Rect rect = offset & size;
37    // 将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾'
38    _drawBackground(context, rect);
39    _drawCheckMark(context, rect);
40    // 调度动画
41    _scheduleAnimation();
42  }
43  
44  // 画背景
45  void _drawBackground(PaintingContext context, Rect rect) {}
46
47  //画 "勾"
48  void _drawCheckMark(PaintingContext context, Rect rect) { }
49  //调度动画
50  void _scheduleAnimation() {}
51
52  ... //响应点击事件
53}

实现布局算法

为了使用户可以自定义宽高,我们的布局策略是:如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25:

1
2void performLayout() {
3  size = constraints.constrain(
4    constraints.isTight ? Size.infinite : Size(25, 25),
5  );
6}

绘制 CustomCheckbox

记下来重点就是绘制 CustomCheckbox 了,为了清洗起见,我们将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾',这里需要注意两点:

  1. 我们绘制的是动画执行过程中的一帧,所以需要通过动画执行的进度(progress)来计算每一帧要绘制的样子。
  2. 当 CustomCheckbox 从未选中变为选中时,我们执行正向动画,progress 的值会从 0 逐渐变为 1,因为 CustomCheckbox 的背景和前景('勾')的颜色要有对比,所以我们在背景绘制完之后再绘制前景。因此,我们将动画分割为两端,前 40% 的时间画背景,后 60%的时间画'勾'。

绘制背景

下面我们先看看如何绘制背景:

  1. 当状态切换为选中状态时,将矩形逐渐从边缘向中心收缩填充,直到填满 Checkbox 区域。
  2. 当状态切换为未选中状态时,填充从中间逐渐向边缘消散,直到只剩一个边框为止。

实现的思路是先将整个背景矩形区域全部填充满蓝色,然后在上面绘制一个白色背景的矩形,根据动画进度来动态改变白色矩形区域大小即可。幸运的是 Canvas API 中已经帮助我们实现了我们期望的功能,drawDRRect 他可以指定内外两个矩形,然后画出不相交的部分,并且可以指定圆角,下面是具体实现:

1void _drawBackground(PaintingContext context, Rect rect) {
2  Color color = value ? fillColor : Colors.grey;
3  var paint = Paint()
4    ..isAntiAlias = true
5    ..style = PaintingStyle.fill //填充
6    ..strokeWidth
7    ..color = color;
8  
9  // 我们需要算出每一帧里面矩形的大小,为此我们可以直接根据矩形插值方法来确定里面矩形
10  final outer = RRect.fromRectXY(rect, radius, radius);
11  var rects = [
12    rect.inflate(-strokeWidth),
13    Rect.fromCenter(center: rect.center, width: 0, height: 0)
14  ];
15  // 根据动画执行进度调整来确定里面矩形在每一帧的大小
16  var rectProgress = Rect.lerp(
17    rects[0],
18    rects[1],
19    // 背景动画的执行时长是前 40% 的时间
20    min(progress, bgAnimationInterval) / bgAnimationInterval,
21  )!;
22  final inner = RRect.fromRectXY(rectProgress, 0, 0);
23  // 绘制
24  context.canvas.drawDRRect(outer, inner, paint);
25}

绘制前景

前景是一个"勾",它有三个点的连线构成,为了简单起见,我们将起始点和中点拐点的位置根据 Checkbox 的大小算出固定的坐标,然后我们在每一帧中动态调整第三个点的位置就可以实现打勾动画:

1//画 "勾"
2void _drawCheckMark(PaintingContext context, Rect rect) {
3  // 在画好背景后再画前景
4  if (progress > bgAnimationInterval) {
5    
6    //确定中间拐点位置
7    final secondOffset = Offset(
8      rect.left + rect.width / 2.5,
9      rect.bottom - rect.height / 4,
10    );
11    // 第三个点的位置
12    final lastOffset = Offset(
13      rect.right - rect.width / 6,
14      rect.top + rect.height / 4,
15    );
16
17    // 我们只对第三个点的位置做插值
18    final _lastOffset = Offset.lerp(
19      secondOffset,
20      lastOffset,
21      (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
22    )!;
23
24    // 将三个点连起来
25    final path = Path()
26      ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2)
27      ..lineTo(secondOffset.dx, secondOffset.dy)
28      ..lineTo(_lastOffset.dx, _lastOffset.dy);
29
30    final paint = Paint()
31      ..isAntiAlias = true
32      ..style = PaintingStyle.stroke
33      ..color = strokeColor
34      ..strokeWidth = strokeWidth;
35
36    context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
37  }
38}

调度动画

最后,我们需要让UI动起来,这时我们回想一下前面动画一章中的内容,会意识到 Flutter 的动画框架是依赖于 StatefulWidget 的,即当状态改变时显式或隐式的去调用 setState 触发更新。但是我们直接通过定义 RenderObject 的方式来实现的 CustomCheckbox,并不是基于 StatefulWidget ,那该怎么来调度动画呢?有两种办法:

  1. 将 CustomCheckbox 用一个 StatefulWidget 包装起来,这样就可以复用之前介绍的执行动画的方法。
  2. 自定义动画调度。

第一种方法相信读者已经很熟悉了,不再赘述,下面我们演示一下第二种方法,我们的思路是:在一帧绘制结束后判断动画是否结束,如果动画未结束,则将将当前组件标记为”需要重绘“,然后等待下一帧即可:

1void _scheduleAnimation() {
2  if (_animationStatus != AnimationStatus.completed) {
3    // 需要在Flutter 当前frame 结束之前再执行,因为不能在绘制过程中又将组件标记为需要重绘
4    SchedulerBinding.instance!.addPostFrameCallback((Duration timeStamp) {
5      if (_lastTimeStamp != null) {
6        double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) /
7          duration.inMilliseconds;
8        // 如果是反向动画,则 progress值要逐渐减小
9        if (_animationStatus == AnimationStatus.reverse) {
10          delta = -delta;
11        }
12        //更新动画进度
13        progress = progress + delta;
14        
15        if (progress >= 1 || progress <= 0) {
16          //动画执行结束
17          _animationStatus = AnimationStatus.completed;
18          progress = progress.clamp(0, 1);
19        }
20      }
21       //标记为需要重绘
22      markNeedsPaint();
23      _lastTimeStamp = timeStamp.inMilliseconds;
24    });
25  } else {
26    _lastTimeStamp = null;
27  }
28}

响应点击事件

根据之前事件处理相关章节的介绍,如果我们要让渲染对象能处理事件,则它必须能通过命中测试,之后才能在 handleEvent 方法中处理事件,所以我们需要添加如下代码:

1// 必须置为true,确保能通过命中测试
2bool hitTestSelf(Offset position) => true;
3
4// 只有通过命中测试,才会调用本方法,我们在手指抬起时触发事件即可
5
6void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
7  if (event.down) {
8    pointerId = event.pointer;
9  } else if (pointerId == event.pointer) {
10    // 手指抬起时触发回调
11    onChanged?.call(!value);
12  }
13}

动画调度抽象 RenderObjectAnimationMixin

我们可以看到,在RenderObject 中调度动画还是比较复杂的,为此我们抽象了一个 RenderObjectAnimationMixin,如果还有其它 RenderObject 中需要执行动画,可以直接复用。

1mixin RenderObjectAnimationMixin on RenderObject {
2  double _progress = 0;
3  int? _lastTimeStamp;
4
5
6
7  // 动画时长,子类可以重写
8  Duration get duration => const Duration(milliseconds: 200);
9  AnimationStatus _animationStatus = AnimationStatus.completed;
10  // 设置动画状态
11  set animationStatus(AnimationStatus v) {
12    if (_animationStatus != v) {
13      markNeedsPaint();
14    }
15    _animationStatus = v;
16  }
17  
18  double get progress => _progress;
19  set progress(double v) {
20    _progress = v.clamp(0, 1);
21  }
22
23  
24  void paint(PaintingContext context, Offset offset) {
25    doPaint(context, offset); // 调用子类绘制逻辑
26    _scheduleAnimation();
27  }
28
29  void _scheduleAnimation() {
30    if (_animationStatus != AnimationStatus.completed) {
31      SchedulerBinding.instance!.addPostFrameCallback((Duration timeStamp) {
32        if (_lastTimeStamp != null) {
33          double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) /
34              duration.inMilliseconds;
35          
36          //在特定情况下,可能在一帧中连续的往frameCallback中添加了多次,导致两次回调时间间隔为0,
37          //这种情况下应该继续请求重绘。
38          if (delta == 0) {
39            markNeedsPaint();
40            return;
41          }
42          
43          if (_animationStatus == AnimationStatus.reverse) {
44            delta = -delta;
45          }
46          _progress = _progress + delta;
47          if (_progress >= 1 || _progress <= 0) {
48            _animationStatus = AnimationStatus.completed;
49            _progress = _progress.clamp(0, 1);
50          }
51        }
52        markNeedsPaint();
53        _lastTimeStamp = timeStamp.inMilliseconds;
54      });
55    } else {
56      _lastTimeStamp = null;
57    }
58  }
59
60  // 子类实现绘制逻辑的地方
61  void doPaint(PaintingContext context, Offset offset);
62}

CustomCheckbox 的完整源码

则最终 CustomCheckbox 的完整源码为

1class CustomCheckbox extends LeafRenderObjectWidget {
2  CustomCheckbox({
3    Key? key,
4    this.strokeWidth = 2.0,
5    this.value = false,
6    this.strokeColor = Colors.white,
7    this.fillColor = Colors.blue,
8    this.radius = 2.0,
9    this.onChanged,
10  }) : super(key: key);
11
12  final double strokeWidth; // “勾”的线条宽度
13  final Color strokeColor; // “勾”的线条宽度
14  final Color? fillColor; // 填充颜色
15  final bool value; //选中状态
16  final double radius; // 圆角
17  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调
18
19  
20  RenderObject createRenderObject(BuildContext context) {
21    return RenderCustomCheckbox(
22      strokeWidth,
23      strokeColor,
24      fillColor ?? Theme.of(context).primaryColor,
25      value,
26      radius,
27      onChanged,
28    );
29  }
30
31  
32  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
33    if (renderObject.value != value) {
34      renderObject.animationStatus =
35          value ? AnimationStatus.forward : AnimationStatus.reverse;
36    }
37    renderObject
38      ..strokeWidth = strokeWidth
39      ..strokeColor = strokeColor
40      ..fillColor = fillColor ?? Theme.of(context).primaryColor
41      ..radius = radius
42      ..value = value
43      ..onChanged = onChanged;
44  }
45}
46// 动画调度相关逻辑直接 with  RenderObjectAnimationMixin即可
47class RenderCustomCheckbox extends RenderBox with RenderObjectAnimationMixin {
48  bool value;
49  int pointerId = -1;
50  double strokeWidth;
51  Color strokeColor;
52  Color fillColor;
53  double radius;
54  ValueChanged<bool>? onChanged;
55
56  RenderCustomCheckbox2(this.strokeWidth, this.strokeColor, this.fillColor,
57      this.value, this.radius, this.onChanged) {
58    progress = value ? 1 : 0;
59  }
60
61  
62  bool get isRepaintBoundary => true;
63
64  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
65  final double bgAnimationInterval = .4;
66
67  
68  void doPaint(PaintingContext context, Offset offset) {
69    Rect rect = offset & size;
70    _drawBackground(context, rect);
71    _drawCheckMark(context, rect);
72  }
73
74  void _drawBackground(PaintingContext context, Rect rect) {
75    Color color = value ? fillColor : Colors.grey;
76    var paint = Paint()
77      ..isAntiAlias = true
78      ..style = PaintingStyle.fill //填充
79      ..strokeWidth
80      ..color = color;
81
82    // 我们对矩形做插值
83    final outer = RRect.fromRectXY(rect, radius, radius);
84    var rects = [
85      rect.inflate(-strokeWidth),
86      Rect.fromCenter(center: rect.center, width: 0, height: 0)
87    ];
88    var rectProgress = Rect.lerp(
89      rects[0],
90      rects[1],
91      min(progress, bgAnimationInterval) / bgAnimationInterval,
92    )!;
93
94    final inner = RRect.fromRectXY(rectProgress, 0, 0);
95    // 画背景
96    context.canvas.drawDRRect(outer, inner, paint);
97  }
98
99  //画 "勾"
100  void _drawCheckMark(PaintingContext context, Rect rect) {
101    // 在画好背景后再画前景
102    if (progress > bgAnimationInterval) {
103
104      //确定中间拐点位置
105      final secondOffset = Offset(
106        rect.left + rect.width / 2.5,
107        rect.bottom - rect.height / 4,
108      );
109      // 第三个点的位置
110      final lastOffset = Offset(
111        rect.right - rect.width / 6,
112        rect.top + rect.height / 4,
113      );
114
115      // 我们只对第三个点的位置做插值
116      final _lastOffset = Offset.lerp(
117        secondOffset,
118        lastOffset,
119        (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
120      )!;
121
122      // 将三个点连起来
123      final path = Path()
124        ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2)
125        ..lineTo(secondOffset.dx, secondOffset.dy)
126        ..lineTo(_lastOffset.dx, _lastOffset.dy);
127
128      final paint = Paint()
129        ..isAntiAlias = true
130        ..style = PaintingStyle.stroke
131        ..color = strokeColor
132        ..strokeWidth = strokeWidth;
133
134      context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
135    }
136  }
137
138  
139  void performLayout() {
140    // 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
141    size = constraints.constrain(
142      constraints.isTight ? Size.infinite : Size(25, 25),
143    );
144  }
145
146  // 必须置为true,否则不可以响应事件
147  bool hitTestSelf(Offset position) => true;
148
149  // 只有通过点击测试的组件才会调用本方法
150  
151  void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
152    if (event.down) {
153      pointerId = event.pointer;
154    } else if (pointerId == event.pointer) {
155      // 判断手指抬起时是在组件范围内的话才触发onChange
156      if(size.contains(event.localPosition)) {
157        onChanged?.call(!value);
158      }
159    }
160  }
161}

测试代码如下:我们创建三个大小不同的复选框,点击其中任意一个,另外两个复选框的状态也会跟着联动:

1class CustomCheckboxTest extends StatefulWidget {
2  const CustomCheckboxTest({Key? key}) : super(key: key);
3
4  
5  State<CustomCheckboxTest> createState() => _CustomCheckboxTestState();
6}
7
8class _CustomCheckboxTestState extends State<CustomCheckboxTest> {
9  bool _checked = false;
10
11  
12  Widget build(BuildContext context) {
13    return Center(
14      child: Column(mainAxisAlignment: MainAxisAlignment.center,
15        children: [
16          CustomCheckbox2(
17            value: _checked,
18            onChanged: _onChange,
19          ),
20          Padding(
21            padding: const EdgeInsets.all(18.0),
22            child: SizedBox(
23              width: 16,
24              height: 16,
25              child: CustomCheckbox(
26                strokeWidth: 1,
27                radius: 1,
28                value: _checked,
29                onChanged: _onChange,
30              ),
31            ),
32          ),
33          SizedBox(
34            width: 30,
35            height: 30,
36            child: CustomCheckbox(
37              strokeWidth: 3,
38              radius: 3,
39              value: _checked,
40              onChanged: _onChange,
41            ),
42          ),
43        ],
44      ),
45    );
46  }
47
48  void _onChange(value) {
49    setState(() => _checked = value);
50  }
51}

总结

本节演示了如何通过自定义 RenderObject 的方式来进行UI绘制、动画调度和事件处理。