参考资料:《Flutter实战·第二版》 10.6 自绘组件:CustomCheckbox
Flutter自带的Checkbox
组件是不能自由指定大小的,本节将通过自定义RenderObject
的方式来实现一个复选框,有助于更深入地理解Flutter组件。 所要完成的复选框具有下面几个要求:
- 有选中和未选中两种状态。
- 状态切换时需要执行动画。
- 可以自定义外观。
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
属性。当value
从false
变为true
时,动画正向执行;反之当value
从true
变为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.isTight
为 true
),那么 Size.infinite
会被用来尝试获取一个尽可能大的尺寸,但由于约束是严格的,最终的大小将会是 constraints
中指定的固定大小。如果约束不是严格的,那么会尝试使用 Size(25, 25)
作为大小,但最终的 size
仍然会受到 constraints
中 minWidth
、maxWidth
、minHeight
和 maxHeight
的限制。
绘制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分别执行createRenderObject
和paint()
画出嵌套边框。 - 随后,当点击复选框区域时,触发命中测试,调用
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
类,再在其中实现动画状态管理、布局和绘制等逻辑。