欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。
在 Flutter 开发中,原生组件往往难以满足个性化的视觉需求 ------ 比如电商 APP 的销量仪表盘、金融 APP 的收益走势图、健身 APP 的运动数据环形图等。而CustomPainter正是解锁 Flutter 视觉定制的 "金钥匙",它允许我们直接操控画布,绘制出任意形态的 UI 元素,且性能远超堆叠的原生组件。本文将从基础原理到实战进阶,手把手教你用CustomPainter打造兼具美感与性能的仪表盘动效,同时解锁可视化图表的核心绘制逻辑。
一、CustomPainter 核心原理:画布的 "魔法棒"
1. 核心概念拆解
CustomPainter的本质是通过Canvas(画布)和Paint(画笔)完成绘制,核心组件包括:
- CustomPainter :抽象类,需实现
paint(Canvas canvas, Size size)和shouldRepaint方法; - Canvas:绘制画布,提供点、线、圆、路径、文本等绘制 API;
- Paint:画笔,控制颜色、粗细、填充方式、抗锯齿等属性;
- Path:路径,组合多个绘制指令,实现复杂图形(如弧形、贝塞尔曲线);
- Animation:结合动画控制器,让静态绘制变为动态动效。
2. 性能优势
相比于用Container+Transform+Opacity等组件堆叠实现复杂图形,CustomPainter有两大核心优势:
- 减少 Widget 树层级 :单个
CustomPaint组件替代数十个原生组件,降低渲染开销; - 精准控制重绘 :通过
shouldRepaint判断是否需要重绘,避免无效刷新; - 硬件加速:绘制操作基于 Skia 引擎,默认开启硬件加速,帧率更稳定。
二、实战 1:打造动态仪表盘组件
我们先实现一个基础的仪表盘组件,包含 "背景环 + 进度环 + 刻度 + 指针 + 数值",并添加进度动画和指针旋转动效。
1. 定义仪表盘配置类
封装可配置参数,让组件具备高度复用性:
dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// 仪表盘配置类
class DashboardConfig {
// 最大数值
final double maxValue;
// 起始角度(弧度),默认140度(钟表10点方向)
final double startAngle;
// 结束角度(弧度),默认220度(钟表2点方向)
final double endAngle;
// 背景环颜色
final Color bgColor;
// 进度环颜色
final Color progressColor;
// 刻度颜色
final Color scaleColor;
// 指针颜色
final Color pointerColor;
// 数值文本样式
final TextStyle textStyle;
// 动画时长
final Duration animationDuration;
const DashboardConfig({
this.maxValue = 100,
this.startAngle = 2.443, // 140° * π/180
this.endAngle = 3.839, // 220° * π/180
this.bgColor = const Color(0xFFE0E0E0),
this.progressColor = const Color(0xFF4CAF50),
this.scaleColor = const Color(0xFF9E9E9E),
this.pointerColor = const Color(0xFFF44336),
this.textStyle = const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
this.animationDuration = const Duration(milliseconds: 1500),
});
}
2. 实现仪表盘 Painter
dart
/// 仪表盘绘制器
class DashboardPainter extends CustomPainter {
// 当前进度值
final double value;
// 配置参数
final DashboardConfig config;
// 动画值(控制进度和指针旋转)
final Animation<double> animation;
DashboardPainter({
required this.value,
required this.config,
required this.animation,
}) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
// 画布中心坐标
final center = Offset(size.width / 2, size.height / 2);
// 仪表盘半径(取宽高中较小值,适配不同尺寸)
final radius = (size.width < size.height ? size.width : size.height) / 2 * 0.8;
// 1. 绘制背景环
_drawBackgroundRing(canvas, center, radius);
// 2. 绘制刻度
_drawScales(canvas, center, radius);
// 3. 绘制进度环(结合动画值)
final progress = value / config.maxValue * animation.value;
_drawProgressRing(canvas, center, radius, progress);
// 4. 绘制指针(结合动画值)
final angle = config.startAngle + (config.endAngle - config.startAngle) * progress;
_drawPointer(canvas, center, radius, angle);
// 5. 绘制数值文本
_drawValueText(canvas, center, progress);
}
/// 绘制背景环
void _drawBackgroundRing(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..color = config.bgColor
..strokeWidth = radius * 0.1 // 环宽度为半径的10%
..strokeCap = StrokeCap.round // 圆角端点
..style = PaintingStyle.stroke; // 描边模式
// 绘制圆弧
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
config.startAngle,
config.endAngle - config.startAngle,
false, // 不填充
paint,
);
}
/// 绘制刻度
void _drawScales(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..color = config.scaleColor
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
// 总刻度数(20个)
const scaleCount = 20;
final angleStep = (config.endAngle - config.startAngle) / scaleCount;
for (int i = 0; i <= scaleCount; i++) {
final angle = config.startAngle + angleStep * i;
// 刻度起始点(外环)
final startX = center.dx + radius * cos(angle);
final startY = center.dy + radius * sin(angle);
// 刻度结束点(内环,长刻度/短刻度区分)
final endRadius = i % 5 == 0 ? radius * 0.85 : radius * 0.9; // 每5个刻度加长
final endX = center.dx + endRadius * cos(angle);
final endY = center.dy + endRadius * sin(angle);
// 绘制单条刻度
canvas.drawLine(
Offset(startX, startY),
Offset(endX, endY),
paint,
);
}
}
/// 绘制进度环
void _drawProgressRing(Canvas canvas, Offset center, double radius, double progress) {
final paint = Paint()
..color = config.progressColor
..strokeWidth = radius * 0.1
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..shader = LinearGradient(
colors: [config.progressColor.withOpacity(0.6), config.progressColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(Rect.fromCircle(center: center, radius: radius)); // 渐变效果
// 进度对应的弧度
final sweepAngle = (config.endAngle - config.startAngle) * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
config.startAngle,
sweepAngle,
false,
paint,
);
}
/// 绘制指针
void _drawPointer(Canvas canvas, Offset center, double radius, double angle) {
final paint = Paint()
..color = config.pointerColor
..style = PaintingStyle.fill;
// 指针路径(三角形)
final path = Path()
..moveTo(
center.dx + radius * 0.7 * cos(angle),
center.dy + radius * 0.7 * sin(angle),
) // 指针尖端
..lineTo(
center.dx - radius * 0.1 * cos(angle - 0.5),
center.dy - radius * 0.1 * sin(angle - 0.5),
) // 指针左底点
..lineTo(
center.dx - radius * 0.1 * cos(angle + 0.5),
center.dy - radius * 0.1 * sin(angle + 0.5),
) // 指针右底点
..close();
// 绘制指针
canvas.drawPath(path, paint);
// 绘制指针中心圆点
canvas.drawCircle(
center,
radius * 0.05,
paint..color = config.pointerColor.withOpacity(0.9),
);
}
/// 绘制数值文本
void _drawValueText(Canvas canvas, Offset center, double progress) {
final text = '${(progress * config.maxValue).toStringAsFixed(0)}';
final textSpan = TextSpan(
text: text,
style: config.textStyle,
);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
);
// 布局文本
textPainter.layout();
// 文本绘制位置(居中)
final textOffset = Offset(
center.dx - textPainter.width / 2,
center.dy + radius * 0.2, // 文本在指针下方
);
textPainter.paint(canvas, textOffset);
}
@override
bool shouldRepaint(covariant DashboardPainter oldDelegate) {
// 仅当值或配置变化时重绘
return oldDelegate.value != value || oldDelegate.config != config;
}
}
3. 封装仪表盘 Widget(带动画)
dart
/// 仪表盘组件
class DashboardWidget extends StatefulWidget {
// 当前值
final double value;
// 配置
final DashboardConfig config;
// 宽度
final double width;
// 高度
final double height;
const DashboardWidget({
super.key,
required this.value,
this.config = const DashboardConfig(),
this.width = 200,
this.height = 200,
});
@override
State<DashboardWidget> createState() => _DashboardWidgetState();
}
class _DashboardWidgetState extends State<DashboardWidget>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
// 初始化动画控制器
_animationController = AnimationController(
vsync: this,
duration: widget.config.animationDuration,
);
// 动画曲线(先慢后快再慢,更自然)
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOutCubic,
);
// 延迟执行动画(避免页面初始化时卡顿)
SchedulerBinding.instance.addPostFrameCallback((_) {
_animationController.forward();
});
}
@override
void didUpdateWidget(covariant DashboardWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// 数值变化时重新执行动画
if (oldWidget.value != widget.value) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(widget.width, widget.height),
painter: DashboardPainter(
value: widget.value,
config: widget.config,
animation: _animation,
),
);
}
}
4. 代码核心解析
(1)角度与弧度转换
仪表盘的弧形绘制基于弧度(而非角度),公式:弧度 = 角度 × π / 180,比如 140° 对应2.443弧度,220° 对应3.839弧度,确保弧形从 10 点到 2 点方向,符合仪表盘视觉习惯。
(2)动画与重绘联动
通过super(repaint: animation)将动画控制器与 Painter 绑定,动画值变化时自动触发重绘;同时在shouldRepaint中精准控制重绘条件,避免无效刷新。
(3)渐变与样式优化
进度环添加线性渐变LinearGradient,让视觉效果更丰富;刻度区分长刻度和短刻度(每 5 个刻度加长),提升可读性;指针采用三角形路径 + 中心圆点,模拟真实仪表盘指针形态。
(4)适配性设计
仪表盘半径基于宽高中较小值计算,确保在不同尺寸下都能完整显示;数值文本居中绘制,且位置在指针下方,符合视觉流程。
三、实战 2:仪表盘组件使用与扩展
1. 基础使用示例
dart
class DashboardDemoPage extends StatefulWidget {
const DashboardDemoPage({super.key});
@override
State<DashboardDemoPage> createState() => _DashboardDemoPageState();
}
class _DashboardDemoPageState extends State<DashboardDemoPage> {
double _currentValue = 75; // 初始值
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("自定义仪表盘示例"),
backgroundColor: Colors.blueAccent,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 基础仪表盘
DashboardWidget(
value: _currentValue,
config: const DashboardConfig(
maxValue: 100,
progressColor: Colors.blueAccent,
pointerColor: Colors.redAccent,
textStyle: TextStyle(fontSize: 28, color: Colors.blueAccent),
),
width: 250,
height: 250,
),
const SizedBox(height: 40),
// 数值调节滑块
Slider(
value: _currentValue,
min: 0,
max: 100,
onChanged: (value) {
setState(() {
_currentValue = value;
});
},
activeColor: Colors.blueAccent,
),
Text("当前值:${_currentValue.toStringAsFixed(0)}"),
],
),
),
);
}
}
2. 扩展:多维度仪表盘(温度 + 湿度 + 风速)
dart
class MultiDashboardPage extends StatelessWidget {
const MultiDashboardPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("多维度仪表盘")),
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 温度仪表盘(0-50℃)
Column(
children: [
const Text("温度"),
DashboardWidget(
value: 28,
config: DashboardConfig(
maxValue: 50,
progressColor: Colors.red,
startAngle: 2.618, // 150°
endAngle: 3.665, // 210°
),
),
],
),
// 湿度仪表盘(0-100%)
Column(
children: [
const Text("湿度"),
DashboardWidget(
value: 65,
config: DashboardConfig(
maxValue: 100,
progressColor: Colors.blue,
),
),
],
),
// 风速仪表盘(0-20m/s)
Column(
children: [
const Text("风速"),
DashboardWidget(
value: 8,
config: DashboardConfig(
maxValue: 20,
progressColor: Colors.green,
),
),
],
),
],
),
),
);
}
}
四、进阶优化与扩展
1. 性能优化技巧
-
减少绘制计算 :将固定不变的绘制参数(如中心坐标、半径)在
paint方法开头计算一次,避免重复计算; -
使用缓存画布 :对于静态部分(如背景环、刻度),可绘制到
PictureRecorder中缓存,减少重复绘制:dart
// 缓存静态绘制内容 late Picture _staticPicture; void _cacheStaticContent(Size size) { final recorder = PictureRecorder(); final canvas = Canvas(recorder); // 绘制背景环、刻度等静态内容 _staticPicture = recorder.endRecording(); } // 在paint中绘制缓存内容 canvas.drawPicture(_staticPicture); -
抗锯齿优化 :在
Paint中添加isAntiAlias: true,提升绘制清晰度(默认开启,但显式声明更稳妥)。
2. 功能扩展
- 添加单位文本:在数值文本下方添加单位(如℃、%、m/s),增强可读性;
- 分段进度色:根据进度值切换进度环颜色(如 0-50 绿色、50-80 黄色、80 + 红色);
- 3D 效果:通过绘制多层弧形叠加,模拟 3D 仪表盘效果;
- 触摸交互 :结合
GestureDetector,支持点击 / 滑动修改仪表盘数值。
3. 适配暗黑模式
扩展DashboardConfig,添加暗黑模式配置:
dart
final Color darkBgColor;
final Color darkProgressColor;
// 在绘制时根据主题切换
color: Theme.of(context).brightness == Brightness.dark
? config.darkBgColor
: config.bgColor,
五、CustomPainter 常见避坑指南
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 绘制内容超出画布 | 未考虑画布尺寸,绘制坐标超出范围 | 基于Size计算绘制范围,使用clamp限制坐标; |
| 动画卡顿 | 在paint中执行耗时计算 |
将耗时计算移到initState/didUpdateWidget中缓存; |
| 文字绘制不居中 | 未正确计算文本宽高 | 使用TextPainter.layout()获取文本尺寸,再计算偏移; |
| 重绘频繁 | shouldRepaint返回 true 过于频繁 |
精准判断重绘条件,仅当关键参数变化时返回 true; |
| 不同设备显示不一致 | 硬编码像素值 | 基于画布尺寸的比例计算绘制参数(如半径 = size.width*0.4)。 |
六、总结
本文从CustomPainter核心原理出发,完整实现了一款可定制、带动画的仪表盘组件,核心亮点在于:
- 模块化设计:将配置、绘制、动画拆分为独立模块,代码结构清晰,易于维护;
- 高性能绘制:通过精准的重绘控制、缓存计算结果,保证 60fps 流畅动效;
- 高度可定制:支持修改颜色、角度、动画时长等参数,适配不同业务场景;
- 视觉优化:加入渐变、刻度区分、指针造型等细节,提升视觉体验。
CustomPainter的潜力远不止仪表盘 ------ 基于本文的核心逻辑,你可以轻松扩展出折线图、柱状图、饼图、自定义按钮、动效图标等任意视觉元素。相比于第三方图表库,自定义绘制的组件更轻量、更贴合业务需求,且能完全掌控性能和样式。
最后,附上完整示例代码仓库(示例):https://github.com/xxx/flutter_custom_dashboard,欢迎大家 Star、Fork,也欢迎在评论区交流CustomPainter的实战技巧和扩展思路!