最近在将Flutter应用适配到鸿蒙nova12u真机时,遇到了一个需求:首页需要展示饼图统计和一个实时走动的时钟表盘。考虑到第三方图表库在鸿蒙上的兼容性问题,我决定用Flutter的CustomPaint组件自行绘制。这篇文章记录了从基础形状到时钟表盘的自定义绘制实践,以及鸿蒙适配中需要注意的细节。
CustomPaint与CustomPainter的关系
CustomPaint是一个Widget,它负责将子组件夹在两层绘制之间;CustomPainter是抽象类,你需要继承它并实现paint方法,在Canvas上绘制内容。
- CustomPaint Widget
- ├── painter: 画前景
- ├── foregroundPainter: 画前景(上层)
- └── child: 子组件(夹在两层绘制中间)
复制代码
关键方法:
- paint(Canvas canvas, Size size) —— 所有绘制逻辑都在这里
- shouldRepaint(covariant CustomPainter oldDelegate) —— 告诉Flutter是否需要重绘,这个返回值容易踩坑
画布、画笔、路径三大核心
Canvas相当于画布,Paint是画笔,Path用于绘制复杂曲线。Flutter的Canvas API与鸿蒙ArkTS的CanvasRenderingContext2D设计思路类似,但使用方式有差异。鸿蒙的Shape组件自由度不如CustomPaint,适合画图表和自定义控件。
基础形状绘制热身
先画几个基础形状试试手:圆形、矩形、圆角矩形、空心圆和椭圆。
- class _ShapePainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- final w = size.width;
- final h = size.height;
- // 实心圆
- canvas.drawCircle(Offset(50, h / 2), 35, Paint()..color = Colors.blue.shade300);
- // 矩形
- canvas.drawRect(Rect.fromLTWH(120, h / 2 - 30, 80, 60), Paint()..color = Colors.red.shade300..style = PaintingStyle.fill);
- // 圆角矩形
- final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(230, h / 2 - 30, 80, 60), const Radius.circular(12));
- canvas.drawRRect(rrect, Paint()..color = Colors.orange.shade300);
- // 空心圆
- canvas.drawCircle(Offset(340, h / 2), 30, Paint()..color = Colors.purple.shade400..style = PaintingStyle.stroke..strokeWidth = 3);
- // 椭圆
- canvas.drawOval(Rect.fromLTWH(400, h / 2 - 20, 100, 50), Paint()..color = Colors.teal.shade300);
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
- }
复制代码
注意PaintingStyle.stroke属性,默认是fill,画空心图形必须改成stroke并设置strokeWidth。刚开始画空心圆时忘记设线宽,结果啥都看不见——因为stroke模式默认线宽为0。
线条和路径:虚线手动绘制,贝塞尔曲线与弧线
对于直线、虚线、贝塞尔曲线和弧线,我会先抽离公共Paint,然后独立绘制每条线。Flutter Canvas没有内置画虚线的方法,需要手动循环绘制短线:
- class _LinePainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()..color = Colors.blue.shade400..strokeWidth = 2;
- // 直线
- canvas.drawLine(const Offset(20, 20), const Offset(150, 20), paint);
- // 手动虚线
- final dashPaint = Paint()..color = Colors.red.shade400..strokeWidth = 2;
- for (double x = 20; x < 300; x += 15) {
- canvas.drawLine(Offset(x, 40), Offset(x + 8, 40), dashPaint);
- }
- // 二次贝塞尔曲线
- final path = Path()
- ..moveTo(20, 80)
- ..quadraticBezierTo(100, 30, 180, 90)
- ..quadraticBezierTo(260, 130, 340, 80);
- canvas.drawPath(path, Paint()..color = Colors.orange.shade400..strokeWidth = 3..style = PaintingStyle.stroke);
- // 弧线
- canvas.drawArc(Rect.fromLTWH(20, 100, 200, 60), -pi / 2, pi * 1.5, false, Paint()..color = Colors.purple.shade400..strokeWidth = 2..style = PaintingStyle.stroke);
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
- }
复制代码
drawArc的最后一个参数useCenter:设为true时画出扇形(饼图切块),false只画弧线本身。绘制饼图时这个参数至关重要。虚线画法虽然简单,但如果需要精确的虚线长度和间距,建议封装为工具方法或使用第三方包。
渐变色:线性、径向与扫掠
渐变通过设置Paint的shader属性实现。Gradient有LinearGradient、RadialGradient、SweepGradient等子类。
- class _GradientPainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- // 线性渐变
- canvas.drawRect(Rect.fromLTWH(0, 0, size.width / 2, size.height),
- Paint()..shader = LinearGradient(colors: [Colors.blue, Colors.red, Colors.orange, Colors.red], begin: Alignment.centerLeft, end: Alignment.centerRight)
- .createShader(Rect.fromLTWH(0, 0, size.width / 2, size.height)));
- // 径向渐变
- canvas.drawCircle(Offset(size.width * 0.75, size.height / 2), 40,
- Paint()..shader = RadialGradient(colors: [Colors.yellow, Colors.orange, Colors.red], stops: const [0, 0.5, 1])
- .createShader(Rect.fromCircle(center: Offset(size.width * 0.75, size.height / 2), radius: 40)));
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
- }
复制代码
createShader需要传入Rect参数指定渐变作用区域。一开始我直接传整个Canvas size,导致径向渐变圆心偏移,改成与绘制区域一致的Rect后才正常。鸿蒙ArkTS中设置渐变的逻辑类似,但Flutter的createShader更灵活,可绑定到任意Paint上。
饼图绘制:数据可视化基本功
饼图由多个drawArc拼成,每个扇形useCenter设为true。
- class _PieChartPainter extends CustomPainter {
- final data = [35.0, 25.0, 20.0, 15.0, 5.0];
- final colors = [Colors.blue, Colors.red, Colors.orange, Colors.purple, Colors.red];
- @override
- void paint(Canvas canvas, Size size) {
- final center = Offset(size.width / 2, size.height / 2);
- final radius = 70.0;
- final total = data.reduce((a, b) => a + b);
- double startAngle = -pi / 2;
- for (var i = 0; i < data.length; i++) {
- final sweepAngle = (data[i] / total) * 2 * pi;
- canvas.drawArc(
- Rect.fromCircle(center: center, radius: radius),
- startAngle, sweepAngle, true,
- Paint()..color = colors[i].shade300,
- );
- // 标注圆点
- final midAngle = startAngle + sweepAngle / 2;
- final textPos = Offset(
- center.dx + (radius + 20) * cos(midAngle),
- center.dy + (radius + 20) * sin(midAngle),
- );
- canvas.drawCircle(textPos, 12, Paint()..color = colors[i].shade200);
- startAngle += sweepAngle;
- }
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
- }
复制代码
起始角度设为-pi/2(12点钟方向),饼图看起来更自然。数据应通过构造函数传入以便复用Painter。
时钟表盘:AnimationController驱动的实时绘制
这是最花心思的部分。表盘需要每秒刷新,用AnimatedBuilder + AnimationController驱动重绘,Controller设每秒repeat,每次tick触发CustomPaint重绘。
- class _ClockWidget extends StatefulWidget {
- const _ClockWidget();
- @override
- State<_ClockWidget> createState() => _ClockWidgetState();
- }
- class _ClockWidgetState extends State<_ClockWidget> with SingleTickerProviderStateMixin {
- late final AnimationController _ctrl;
- @override
- void initState() {
- super.initState();
- _ctrl = AnimationController(vsync: this);
- _ctrl.repeat(period: const Duration(seconds: 1));
- }
- @override
- void dispose() {
- _ctrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- return AnimatedBuilder(
- animation: _ctrl,
- builder: (context, child) {
- return SizedBox(
- height: 200,
- child: CustomPaint(
- painter: _ClockPainter(),
- size: const Size(double.infinity, 200),
- ),
- );
- },
- );
- }
- }
复制代码
Painter每次paint取当前时间计算角度:
- class _ClockPainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- final center = Offset(size.width / 2, size.height / 2);
- final radius = 80.0;
- final now = DateTime.now();
- // 表盘外圈
- canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade100..style = PaintingStyle.fill);
- canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade400..style = PaintingStyle.stroke..strokeWidth = 2);
- // 12个刻度(0/3/6/9主刻度加粗加长)
- for (var i = 0; i < 12; i++) {
- final angle = -pi / 2 + (i * 2 * pi / 12);
- final isMain = i % 3 == 0;
- final inner = radius - (isMain ? 15 : 8);
- final outer = radius - 5;
- canvas.drawLine(
- Offset(center.dx + inner * cos(angle), center.dy + inner * sin(angle)),
- Offset(center.dx + outer * cos(angle), center.dy + outer * sin(angle)),
- Paint()..color = isMain ? Colors.black87 : Colors.grey.shade500..strokeWidth = isMain ? 2.5 : 1.5,
- );
- }
- // 时针
- final hourAngle = -pi / 2 + (now.hour % 12) * 2 * pi / 12 + now.minute * 2 * pi / (12 * 60);
- canvas.drawLine(center, Offset(center.dx + 35 * cos(hourAngle), center.dy + 35 * sin(hourAngle)),
- Paint()..color = Colors.black87..strokeWidth = 4..strokeCap = StrokeCap.round);
- // 分针
- final minAngle = -pi / 2 + now.minute * 2 * pi / 60;
- canvas.drawLine(center, Offset(center.dx + 50 * cos(minAngle), center.dy + 50 * sin(minAngle)),
- Paint()..color = Colors.black87..strokeWidth = 2.5..strokeCap = StrokeCap.round);
- // 秒针
- final secAngle = -pi / 2 + now.second * 2 * pi / 60;
- canvas.drawLine(center, Offset(center.dx + 60 * cos(secAngle), center.dy + 60 * sin(secAngle)),
- Paint()..color = Colors.red.shade400..strokeWidth = 1.5..strokeCap = StrokeCap.round);
- // 中心圆
- canvas.drawCircle(center, 4, Paint()..color = Colors.black87);
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
- }
复制代码
指针长度经过调试:时针35、分针50、秒针60,strokeCap设为round使端头圆润。
踩坑汇总
1. shouldRepaint返回false导致画面不动:静态图形(饼图)返回false可避免性能浪费,动态时钟必须返回true。
2. CustomPaint的size约束:不给child时必须显式设置size或用SizedBox包裹,否则size默认为零导致图形不显示。
3. 手动画虚线粗糙:如需精确虚线长度和间距,建议封装工具方法或使用dash_painter包。
4. Canvas坐标原点:左上角,y轴向下为正,角度顺时针为正。时钟从-pi/2(12点)开始。
5. 重绘性能:时钟每秒一次没问题,复杂动画建议用RepaintBoundary隔离绘制图层,鸿蒙上同样有效。
与鸿蒙ArkTS Canvas的比较
鸿蒙ArkTS的CanvasRenderingContext2D与Flutter Canvas设计思路相似,但API命名不同。举例来说,鸿蒙用ctx.fillRect绘制矩形,Flutter用canvas.drawRect。在nova12u真机上,Flutter的CustomPaint每秒刷新流畅不掉帧,渲染引擎走Skia或Impeller,性能接近原生。
总结
通过这次实践,我掌握了Flutter CustomPaint从基础形状到时钟表盘的完整用法。关键细节包括shouldRepaint返回值、Canvas坐标方向、渐变的createShader区域等。下一步计划对饼图增加点击切换功能(通过hitTest判断扇形区域)。验证环境:Flutter · HarmonyOS 6.0 · nova12u 真机演示。 |