参考资料:《Flutter实战·第二版》10.7 自绘组件: DoneWidget
本节将实现一个DoneWidget,可以在创建时执行一个打勾的动画。组件包含以下几个属性:
| 属性名称 | 含义 | 默认值 |
|---|---|---|
| strokeWidth | 对勾线条宽度 | 2.0 |
| color | 颜色 | 任意 |
| outline | 是否为边框模式 | false |
实现思路仍是先继承LeafRenderObjectWidget类,实现createRenderObject和updateRenderObject两个函数,前者返回一个RenderDoneObject对象,其继承自RenderBox类,要实现布局、绘制、动画管理等功能。对于动画管理,同之前10.6节 自绘组件:CustomCheckbox讲述的相同,都是通过自行抽象的RenderObjectAnimationMixin来实现动画效果的。
动画执行时间可以通过复写代码来进行抽象:
// 动画执行时间为 300ms
@override
Duration get duration => const Duration(milliseconds: 300);
绘制时可以通过曲线将progress变量映射为另外的值,用于实现一些生动的动画效果。该组件分为两种模式,outline为false时绘制填满的圆,而outline为true时绘制空心圆,分别对应Paint.style中的PaintingStyle.stroke和PaintingStyle.fill两种绘制形式。注意如果是空心模式,需要设置画笔粗细。
与之前一节不同,这里在60%的时间绘制第一、第二点之间的动画,在剩下的40%时间内绘制二、三点之间的动画。同样,这里仍然用插值法,提供Offset的插值比例。最后是绘制了一个完整的路径,每一帧都是如此,实际上计算过程是在描述每一帧的UI外观。
我们仍然要在performLayout()中指定组件为父组件大小或者自定义大小。
完整的实现代码如下:
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 const Center(
child: Column(children: [
SizedBox(
height: 10,
),
SizedBox(
width: 50,
height: 50,
child: DoneWidget(
outline: false,
),
),
SizedBox(
height: 10,
),
SizedBox(
width: 50,
height: 50,
child: DoneWidget(
strokeWidth: 5,
outline: true,
),
)
],),
);
}
}
class DoneWidget extends LeafRenderObjectWidget {
const DoneWidget({
Key? key,
this.strokeWidth = 2.0,
this.color = Colors.teal,
this.outline = false,
}) : super(key: key);
//线条宽度
final double strokeWidth;
//轮廓颜色或填充色
final Color color;
//如果为true,则没有填充色,color代表轮廓的颜色;如果为false,则color为填充色
final bool outline;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderDoneObject(
strokeWidth,
color,
outline,
)..animationStatus = AnimationStatus.forward; // 创建时执行正向动画
}
@override
void updateRenderObject(context, RenderDoneObject renderObject) {
renderObject
..strokeWidth = strokeWidth
..outline = outline
..color = color;
}
}
class RenderDoneObject extends RenderBox with RenderObjectAnimationMixin {
double strokeWidth;
Color color;
bool outline;
ValueChanged<bool>? onChanged;
RenderDoneObject(
this.strokeWidth,
this.color,
this.outline,
);
// 动画执行时间为 300ms
@override
Duration get duration => const Duration(milliseconds: 300);
@override
void doPaint(PaintingContext context, Offset offset) {
// 可以对动画运用曲线
Curve curve = Curves.easeIn;
final _progress = curve.transform(progress);
Rect rect = offset & size;
final paint = Paint()
..isAntiAlias = true
..style = outline ? PaintingStyle.stroke : PaintingStyle.fill //填充
..color = color;
if (outline) {
paint.strokeWidth = strokeWidth;
rect = rect.deflate(strokeWidth / 2);
}
// 画背景圆
context.canvas.drawCircle(rect.center, rect.shortestSide / 2, paint);
paint
..style = PaintingStyle.stroke
..color = outline ? color : Colors.white
..strokeWidth = strokeWidth;
final path = Path();
Offset firstOffset =
Offset(rect.left + rect.width / 6, rect.top + rect.height / 2.1);
final secondOffset = Offset(
rect.left + rect.width / 2.5,
rect.bottom - rect.height / 3.3,
);
path.moveTo(firstOffset.dx, firstOffset.dy);
const adjustProgress = .6;
//画 "勾"
if (_progress < adjustProgress) {
//第一个点到第二个点的连线做动画(第二个点不停的变)
Offset _secondOffset = Offset.lerp(
firstOffset,
secondOffset,
_progress / adjustProgress,
)!;
path.lineTo(_secondOffset.dx, _secondOffset.dy);
} else {
//链接第一个点和第二个点
path.lineTo(secondOffset.dx, secondOffset.dy);
//第三个点位置随着动画变,做动画
final lastOffset = Offset(
rect.right - rect.width / 5,
rect.top + rect.height / 3.5,
);
Offset _lastOffset = Offset.lerp(
secondOffset,
lastOffset,
(progress - adjustProgress) / (1 - adjustProgress),
)!;
path.lineTo(_lastOffset.dx, _lastOffset.dy);
}
context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
}
@override
void performLayout() {
// 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
size = constraints.constrain(
constraints.isTight ? Size.infinite : const Size(25, 25),
);
}
}
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);
}
实现效果如下: