鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩
最近在将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: , 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: , stops: const )
.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 = ;
final colors = ;
@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 / total) * 2 * pi;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle, sweepAngle, true,
Paint()..color = colors.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.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 真机演示。
Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩
感谢分享!同是鸿蒙适配踩坑人,CustomPaint在真机上的表现确实跟模拟器有差异,特别是shouldRepaint没处理好导致掉帧的问题,你提的这个点很关键。虚线手动绘制那段我也遇到过,后来干脆封装了个DashPainter工具类,需要的话可以交流。另外drawArc的useCenter参数在饼图里确实容易搞混,我之前画扇形时忘了设true,结果只出来一段弧线,排查了半天😂。期待后续时钟表盘的实现细节~Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩
感谢分享!这篇文章太及时了,正好最近也在尝试把Flutter应用往鸿蒙上迁移,CustomPaint的适配踩坑经验非常实用。 你提到的“shouldRepaint”和“stroke默认线宽0”这两个点,我之前也中招过,特别是画空心图形时什么都看不到,排查了半天才发现是线宽没设。手动画虚线的方法也亲测好用,Flutter Canvas没有内置虚线确实有点不方便,封装成工具方法是个好主意。 另外,想问一下你在鸿蒙nova12u上绘制时钟表盘时,对于指针实时更新的动画循环,是用了Timer配合setState还是其他方案?我担心鸿蒙的刷新率和Flutter的帧同步会不会有额外开销。期待你后续关于时钟和饼图具体实现的更新~Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩
感谢楼主分享!正好最近也在折腾鸿蒙上的Flutter适配,CustomPaint这块一直没敢碰,怕遇到奇怪的兼容问题。你列的几个坑点很实在,特别是PaintingStyle.stroke默认线宽为0这个,之前在普通Flutter开发里也踩过,没想到鸿蒙上一样中招。手动画虚线那部分也很实用,之前用dart:ui的dashPath效果不理想,看来还是自己循环更可控。想问下楼主,时钟表盘里秒针的平滑运动,在鸿蒙真机上刷新率能跟住吗?有没有遇到Canvas性能瓶颈?
页:
[1]