前言
大家好,在Flutter的广阔天地中,我们拥有丰富多样的Widget,从基础的Container到复杂的ListView,它们构成了我们精美应用的基石。然而,当UI设计稿出现一些高度定制化、不规则的图形时------比如一个动态的仪表盘、一个独特的图表,或者一个带有复杂路径的Logo------标准的Widget便会显得力不从心。
这时,Flutter提供的"屠龙之技"------自定义绘制 便登上了舞台。通过CustomPainter,我们可以像在画布上作画一样,精确控制屏幕上的每一个像素。
本文旨在带领大家从零开始,深入理解Flutter的自定义绘制机制。我们将不仅仅满足于讲解理论,更会通过一个实战项目:从零到一构建一个带有动画效果的炫酷仪表盘,来巩固所学。文章质量对标CSDN优质专栏,力求结构清晰、代码详尽、深入浅出。无论你是对自定义绘制感到好奇的初学者,还是希望提升技能的中级开发者,相信都能从中获益。
一、 核心武器库:CustomPainter、Canvas 与 Paint
在开始绘制之前,我们必须先熟悉我们手中的三件核心武器:CustomPainter、Canvas和Paint。
1.1 CustomPainter:指挥官
CustomPainter是一个抽象类,我们的核心绘制逻辑都将封装在一个继承自它的类中。它主要有两个方法需要我们实现:
void paint(Canvas canvas, Size size): 绘制方法 。这是我们的主战场,系统会把一块"画布"(Canvas对象)和画布的尺寸(Size对象)传给我们。所有的绘制指令都在这里调用。bool shouldRepaint(covariant CustomPainter oldDelegate): 重绘判断方法 。当外部状态(如数据、动画值)发生变化时,Flutter会询问是否需要重绘。返回true则调用paint方法进行重绘,返回false则复用上一次的绘制结果。为了性能优化,精确控制这里的逻辑至关重要。
1.2 Canvas:画布
Canvas对象就是我们进行绘制操作的画布。它提供了大量的绘制方法,比如:
drawLine(Offset p1, Offset p2, Paint paint): 画线。drawCircle(Offset c, double radius, Paint paint): 画圆。drawRect(Rect rect, Paint paint): 画矩形。drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint): 画弧线/扇形。drawPath(Path path, Paint paint): 画路径,这是实现复杂图形的终极武器。
可以把Canvas想象成一块坐标系,原点(0, 0)在左上角,x轴向右延伸,y轴向下延伸。
1.3 Paint:画笔
如果说Canvas是画布,那Paint就是我们的画笔。它定义了绘制的样式,比如:
color: 颜色。style: 绘制模式,PaintingStyle.fill(填充)还是PaintingStyle.stroke(描边)。strokeWidth: 描边宽度。isAntiAlias: 是否开启抗锯齿,建议默认开启true,让边缘更平滑。shader: 着色器,可以实现渐变色等高级效果。
一个简单的例子:画一条线
class MyPainter extends CustomPainter {
// 定义画笔,通常在类外部创建并复用,性能更好
final Paint _paint = Paint()
..color = Colors.blue
..strokeWidth = 4.0
..isAntiAlias = true;
@override
void paint(Canvas canvas, Size size) {
// 从画布左上角(10, 10)画一条线到右下角
final startPoint = Offset(10, 10);
final endPoint = Offset(size.width - 10, size.height - 10);
canvas.drawLine(startPoint, endPoint, _paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
// 这里内容固定,永远不需要重绘
return false;
}
}
要使用这个Painter,我们需要用CustomPaint这个Widget包裹它:
CustomPaint(
size: Size(300, 200), // 指定绘制区域大小
painter: MyPainter(),
)
二、 绘制基础图形:构建仪表盘的基石
仪表盘由多种基础图形组合而成:外圈的弧线、刻度、指针、中心的数值。本节我们逐一击破。
2.1 绘制弧线:drawArc
drawArc是绘制仪表盘刻度和进度的核心。它的签名是:
void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
rect: 定义一个矩形,弧线将在其内切圆上绘制。startAngle: 起始角度,单位是弧度 。0弧度指向时钟3点方向,pi/2指向6点,pi指向9点,3*pi/2指向12点。sweepAngle: 扫过的角度,单位也是弧度。正值顺时针,负值逆时针。useCenter:true时绘制扇形(连接弧线两端点和圆心),false时只绘制弧线。
角度转换小技巧 :我们习惯用度,Flutter用弧度。弧度 = 度 * pi / 180。
2.2 绘制文本:TextPainter
在画布上直接绘制文本稍显麻烦,通常我们使用TextPainter这个辅助类。它的使用步骤如下:
- 创建
TextPainter对象。 - 通过
text属性设置TextSpan(可以定义文本内容、样式)。 - 调用
layout()方法进行布局,计算文本占用的宽高。 - 通过
paint()方法将其绘制到Canvas上。
final textPainter = TextPainter(
text: TextSpan(
text: "80",
style: TextStyle(color: Colors.white, fontSize: 40),
),
textDirection: TextDirection.ltr, // 必须指定文本方向
);
textPainter.layout();
// 将文本绘制在画布中心
final offset = Offset(
(size.width - textPainter.width) / 2,
(size.height - textPainter.height) / 2,
);
textPainter.paint(canvas, offset);
2.3 坐标计算:让刻度"长"在圆上
绘制刻度线,需要计算出它在圆周上的起点和终点坐标。这离不开三角函数。
假设圆心为(centerX, centerY),半径为radius。一个角度为angle(弧度)的点,其坐标为:
x = centerX + radius * cos(angle)y = centerY + radius * sin(angle)
通过这个公式,我们可以计算出每个刻度线内外端点的坐标,然后用drawLine连接。
三、 实战演练:构建动态仪表盘
理论结合实践,我们现在就来构建一个完整的、带动画的仪表盘。它将具备以下功能:
- 一个半圆形的底座。
- 均匀分布的刻度线。
- 一个跟随数值变化的、颜色渐变的进度弧。
- 一个平滑旋转的指针。
- 中心显示当前数值。
步骤1:项目结构搭建
创建一个新的Flutter项目,我们主要在main.dart中操作。
- 创建
DashboardPainter,继承自CustomPainter。 - 创建
Dashboardwidget,负责管理状态(当前值)和动画,并使用CustomPaint来展示DashboardPainter。 - 在
MyApp中调用Dashboard。
步骤2:绘制静态背景与刻度
我们先不考虑动画,把仪表盘的静态部分画出来。
// 在DashboardPainter中
class DashboardPainter extends CustomPainter {
final double currentValue;
final double maxValue;
// ... 构造函数
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// 1. 定义画笔
final bgPaint = Paint()
..color = Colors.grey[300]!
..style = PaintingStyle.stroke
..strokeWidth = 20
..isAntiAlias = true;
final tickPaint = Paint()
..color = Colors.black87
..strokeWidth = 2
..isAntiAlias = true;
// 2. 绘制背景弧线(半圆形)
const startAngle = pi; // 从180度(9点钟方向)开始
const sweepAngle = pi; // 扫过180度
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, bgPaint);
// 3. 绘制刻度
const tickCount = 20;
const tickAngleTotal = pi;
final tickAngleStep = tickAngleTotal / (tickCount - 1);
for (int i = 0; i < tickCount; i++) {
final angle = startAngle + i * tickAngleStep;
final tickStartRadius = radius - 30;
final tickEndRadius = radius - (i % 5 == 0 ? 40 : 35); // 每5个刻度加长
final startX = center.dx + tickStartRadius * cos(angle);
final startY = center.dy + tickStartRadius * sin(angle);
final endX = center.dx + tickEndRadius * cos(angle);
final endY = center.dy + tickEndRadius * sin(angle);
canvas.drawLine(Offset(startX, startY), Offset(endX, endY), tickPaint);
}
}
@override
bool shouldRepaint(covariant DashboardPainter oldDelegate) {
return oldValue != oldDelegate.currentValue;
}
}
步骤3:添加动态进度与指针
现在,我们把静态的仪表盘和currentValue这个状态关联起来。
// 在DashboardPainter的paint方法中继续添加
// ... (之前的背景绘制代码)
final progressPaint = Paint()
..shader = LinearGradient(
colors: [Colors.green, Colors.orange, Colors.red],
stops: [0.0, 0.6, 1.0],
).createShader(Rect.fromCircle(center: center, radius: radius))
..style = PaintingStyle.stroke
..strokeWidth = 20
..strokeCap = StrokeCap.round // 让线段末端是圆角
..isAntiAlias = true;
final pointerPaint = Paint()
..color = Colors.red
..strokeWidth = 4
..strokeCap = StrokeCap.round
..isAntiAlias = true;
// 4. 绘制进度弧线
final progressRatio = currentValue / maxValue;
final progressSweepAngle = progressRatio * sweepAngle;
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, progressSweepAngle, false, progressPaint);
// 5. 绘制指针
final pointerAngle = startAngle + progressSweepAngle;
final pointerLength = radius - 50;
final pointerEndX = center.dx + pointerLength * cos(pointerAngle);
final pointerEndY = center.dy + pointerLength * sin(pointerAngle);
canvas.drawLine(center, Offset(pointerEndX, pointerEndY), pointerPaint);
// 6. 绘制中心圆点
canvas.drawCircle(center, 8, pointerPaint);
// 7. 绘制中心数值
final textPainter = TextPainter(
text: TextSpan(
text: currentValue.toInt().toString(),
style: TextStyle(color: Colors.black, fontSize: 48, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr);
textPainter.layout();
textPainter.paint(canvas, Offset(center.dx - textPainter.width / 2, center.dy - textPainter.height / 2));
步骤4:整合与动画
现在,我们在Dashboard widget中引入AnimationController来驱动数值变化,从而触发重绘和动画。
class Dashboard extends StatefulWidget {
@override
_DashboardState createState() => _DashboardState();
}
class _DashboardState extends State<Dashboard> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double _currentValue = 0.0;
final double _maxValue = 100.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
);
_animation = Tween(begin: 0.0, end: 85.0).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic, // 使用一个缓动曲线,让动画更自然
));
_animation.addListener(() {
setState(() {
_currentValue = _animation.value;
});
});
_controller.forward(); // 启动动画
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("动态仪表盘")),
body: Center(
child: SizedBox(
width: 300,
height: 300,
child: CustomPaint(
painter: DashboardPainter(currentValue: _currentValue, maxValue: _maxValue),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_controller.reset(); // 重置动画
_controller.forward(); // 再次播放
},
child: Icon(Icons.refresh),
),
);
}
}
至此,一个炫酷的动态仪表盘就完成了!当你运行项目,会看到仪表盘的指针和进度弧从0平滑地动画到85,点击右下角的按钮可以重播动画。
四、 进阶技巧与性能考量
4.1 性能优化:对象复用
在paint方法中,应避免创建新对象 。paint方法在动画期间会被以60fps的频率频繁调用,如果在其中创建Paint、TextPainter等对象,会产生大量垃圾回收,导致卡顿。
最佳实践 :将Paint、TextPainter等对象作为成员变量,在CustomPainter的构造函数中初始化,并在paint方法中复用。
class OptimizedDashboardPainter extends CustomPainter {
final Paint _bgPaint = Paint();
final Paint _tickPaint = Paint();
final Paint _progressPaint = Paint();
// ... 其他paint对象
final TextPainter _textPainter = TextPainter(textDirection: TextDirection.ltr);
// 在构造函数或首次使用时设置一次
OptimizedDashboardPainter() {
_bgPaint
..color = Colors.grey[300]!
..style = PaintingStyle.stroke
..strokeWidth = 20;
// ...
}
@override
void paint(Canvas canvas, Size size) {
// 直接使用已创建的paint对象,只修改需要动态改变的部分
_progressPaint.shader = // ...创建新的shader是允许的,因为它是轻量级资源
// ...
}
}
4.2 响应式绘制
上面的例子使用了固定的SizedBox(300, 300),这不利于适配不同屏幕。更好的做法是让CustomPaint自行计算尺寸,或者在父widget中根据屏幕比例动态确定尺寸。所有绘制坐标都应基于传入的size参数进行相对计算,而不是硬编码。
4.3 save() 与 restore()
Canvas提供了save()和restore()方法,用于保存和恢复当前的绘制状态(如变换矩阵、裁剪区域等)。在进行旋转、平移、缩放等复杂操作前,先save(),操作完成后restore(),可以避免影响后续的绘制操作,非常实用。
总结
通过本文的学习,我们不仅掌握了CustomPainter、Canvas、Paint这三大核心工具,还亲手实践了一个包含渐变、动画、精确计算的复杂UI组件------动态仪表盘。
回顾一下关键知识点:
- 核心API :理解
CustomPainter.paint和shouldRepaint的职责。 - 绘制基础 :熟练使用
drawArc,drawLine,drawPath等,并掌握TextPainter绘制文本的技巧。 - 坐标计算:运用三角函数解决圆周上的定位问题。
- 动画驱动 :将
AnimationController与setState结合,驱动自定义绘制的重绘,实现流畅动画。 - 性能为王 :牢记复用
Paint等对象,避免在paint方法内进行不必要的对象创建。
自定义绘制是Flutter高级开发者必备的技能,它为你打开了通往任意复杂UI的大门。希望这篇文章能为你打下坚实的基础。现在,不妨发挥你的创意,尝试用今天所学去创造一个独一无二的、属于你自己的UI组件吧!
欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。