前言
大家好,在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:整合与动画
现在,我们在Dashboardwidget中引入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),一起共建开源鸿蒙跨平台生态。