Flutter: 自定义绘制与 CustomPainter
当标准组件不够用,Canvas 就是你的画布——任何 UI 都可以一笔一划画出来。
📋 前置知识:需要先掌握以下内容
- 第15课:动画系统
1. 你将学到
- CustomPaint + CustomPainter:paint() / shouldRepaint() 生命周期
- Canvas API:drawLine、drawRect、drawCircle、drawPath、drawParagraph
- 手势集成:GestureDetector + CustomPainter 实现签名板
- 性能优化:shouldRepaint 精准判断 + RepaintBoundary 隔离
- ShopApp:销售数据折线图 + 手写签名组件
2. 一个图表需求的故事
(1) 痛点:第三方图表库定制性差
Bob 的 ShopApp 需要一个销售趋势折线图,要求:渐变填充、点击数据点显示详情、动画绘制。第三方图表库要么不支持渐变,要么点击回调不灵活,要么动画不可控。Bob 用 fl_chart 实现了 80% 的需求,剩下 20% 改不动源码。
(2) CustomPainter 的解法
CustomPainter 给你完全的绘制控制权:每一笔、每个像素都由你决定。
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - data: List<double> 类型,表示销售数据点
class SalesChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Full control: draw exactly what you need
final linePaint = Paint()..color = Colors.blue..strokeWidth = 2..style = PaintingStyle.stroke;
final fillPaint = Paint()..shader = LinearGradient(colors: [Colors.blue.withOpacity(0.3), Colors.transparent])
.createShader(Rect.fromLTWH(0, 0, size.width, size.height));
// Draw path, fill, dots, labels...
}
@override
bool shouldRepaint(covariant SalesChartPainter old) => old.data != data;
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(3) 收益:100% 定制 + 无第三方依赖
Bob 用 CustomPainter 实现了完整需求,代码仅 200 行,零第三方依赖,动画完全可控。
3. CustomPainter 体系
graph LR
CP[CustomPaint] --> Painter[CustomPainter]
Painter --> Canvas[Canvas API]
Canvas --> DL[drawLine]
Canvas --> DR2[drawRect]
Canvas --> DC[drawCircle]
Canvas --> DP2[drawPath]
Painter --> |shouldRepaint| RP[Repaint Check]
CP --> RB[RepaintBoundary]
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(1) CustomPainter 生命周期
| 方法 | 触发时机 | 用途 |
|---|---|---|
paint() |
首次绘制或 shouldRepaint 返回 true | 执行绘制命令 |
shouldRepaint() |
新旧 Painter 对比 | 决定是否重绘 |
repaint() |
通知 CustomPaint 需要重绘 | 配合 AnimationController |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:基础 CustomPainter
DART
import 'package:flutter/material.dart';
class PriceIndicatorPainter extends CustomPainter {
final double price;
final double maxPrice;
final Color color;
PriceIndicatorPainter({
required this.price,
required this.maxPrice,
this.color = Colors.green,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..color = color.withOpacity(0.2)
..style = PaintingStyle.fill;
// Calculate bar height
final barHeight = (price / maxPrice) * size.height;
final rect = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
// Draw filled bar
canvas.drawRect(rect, fillPaint);
// Draw border
canvas.drawRect(rect, paint);
// Draw price text
final textSpan = TextSpan(text: '\$${price.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 12));
final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, size.height - barHeight - 16));
}
@override
bool shouldRepaint(covariant PriceIndicatorPainter old) =>
old.price != price || old.maxPrice != maxPrice;
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
4. Canvas API 详解
(1) 常用绘制方法
| 方法 | 说明 | 参数 |
|---|---|---|
drawLine |
画线 | p1, p2, paint |
drawRect |
画矩形 | rect, paint |
drawRRect |
画圆角矩形 | rrect, paint |
drawCircle |
画圆 | center, radius, paint |
drawOval |
画椭圆 | rect, paint |
drawArc |
画弧 | rect, startAngle, sweepAngle, useCenter, paint |
drawPath |
画路径 | path, paint |
drawShadow |
画阴影 | path, color, elevation, transparentOccluder |
drawParagraph |
画文字 | paragraph, offset |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:绘制渐变折线图
DART
import 'package:flutter/material.dart';
class LineChartPainter extends CustomPainter {
final List<double> dataPoints;
final Color lineColor;
final double maxValue;
LineChartPainter({
required this.dataPoints,
this.lineColor = Colors.blue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
if (dataPoints.isEmpty) return;
final linePaint = Paint()
..color = lineColor
..strokeWidth = 2.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final dotPaint = Paint()
..color = lineColor
..style = PaintingStyle.fill;
final gridPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 0.5;
// Draw grid lines
for (int i = 0; i <= 4; i++) {
final y = size.height * i / 4;
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Calculate points
final points = <Offset>[];
for (int i = 0; i < dataPoints.length; i++) {
final x = (i / (dataPoints.length - 1)) * size.width;
final y = size.height - (dataPoints[i] / maxValue) * size.height;
points.add(Offset(x, y));
}
// Draw gradient fill
final fillPath = Path()
..moveTo(points.first.dx, size.height)
..lineTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
fillPath.lineTo(points[i].dx, points[i].dy);
}
fillPath.lineTo(points.last.dx, size.height);
fillPath.close();
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.05)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawPath(fillPath, fillPaint);
// Draw line
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(linePath, linePaint);
// Draw dots
for (final point in points) {
canvas.drawCircle(point, 4, dotPaint);
}
}
@override
bool shouldRepaint(covariant LineChartPainter old) =>
old.dataPoints != dataPoints || old.maxValue != maxValue;
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
5. 手势集成:签名板
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:SignaturePad 组件
DART
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class SignaturePad extends StatefulWidget {
final ValueChanged<ui.Image>? onSignatureComplete;
const SignaturePad({super.key, this.onSignatureComplete});
@override
State<SignaturePad> createState() => _SignaturePadState();
}
class _SignaturePadState extends State<SignaturePad> {
final List<Offset?> _points = [];
final _painterKey = GlobalKey();
void _onPointerDown(PointerDownEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerMove(PointerMoveEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerUp(PointerUpEvent event) {
setState(() => _points.add(null)); // null = line break
}
void _clear() => setState(() => _points.clear());
@override
Widget build(BuildContext context) {
return Column(children: [
Container(
height: 200,
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(key: _painterKey, size: Size.infinite, painter: SignaturePainter(_points)),
),
),
),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: _clear, child: const Text('Clear')),
const SizedBox(width: 8),
FilledButton(onPressed: () {
// Capture signature as image
widget.onSignatureComplete?.call(null); // Simplified
}, child: const Text('Confirm')),
]),
]);
}
}
class SignaturePainter extends CustomPainter {
final List<Offset?> points;
SignaturePainter(this.points);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 2.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(covariant SignaturePainter old) => old.points != points;
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
6. 性能优化
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:RepaintBoundary 隔离重绘
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - ProductHeader/ProductDescription: 自定义 Widget
// - LineChartPainter: 见本课第4节
// - salesData: List<double> 类型
// Without RepaintBoundary: entire page repaints on chart update
Column(children: [
ProductHeader(product: product),
RepaintBoundary( // Only chart repaints
child: CustomPaint(painter: LineChartPainter(data: salesData)),
),
ProductDescription(product: product), // NOT repainted
])
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
| 优化技巧 | 效果 |
|---|---|
shouldRepaint 精准判断 |
避免不必要重绘 |
RepaintBoundary |
隔离重绘范围 |
PictureRecorder 缓存 |
复杂图形缓存为 Image |
| 避免在 paint 中创建对象 | 减少GC压力 |
7. 完整示例:ShopApp 销售图表组件
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - LineChartPainter: 见本课第4节
// - SalesData: 见下方定义
class SalesChart extends StatelessWidget {
final List<SalesData> data;
final double width;
final double height;
const SalesChart({super.key, required this.data, this.width = 350, this.height = 200});
@override
Widget build(BuildContext context) {
final maxRevenue = data.map((d) => d.revenue).reduce((a, b) => a > b ? a : b);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Monthly Revenue (USD)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
SizedBox(width: width, height: height,
child: RepaintBoundary(child: CustomPaint(
painter: LineChartPainter(
dataPoints: data.map((d) => d.revenue).toList(),
maxValue: maxRevenue * 1.1,
lineColor: Theme.of(context).colorScheme.primary,
),
size: Size(width, height),
)),
),
const SizedBox(height: 8),
// Legend
Wrap(spacing: 16, children: data.take(6).map((d) => Text(
'${d.month}: \$${(d.revenue / 1000).toStringAsFixed(0)}K',
style: const TextStyle(fontSize: 11),
)).toList()),
]),
),
);
}
}
class SalesData {
final String month;
final double revenue;
const SalesData({required this.month, required this.revenue});
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
❓ 常见问题
Q CustomPainter 什么时候需要 repaint?
A 当 Painter 的属性变化时。必须正确实现 shouldRepaint:比较新旧属性,变化返回 true。
Q CustomPaint 的 size 怎么确定?
A 如果有 child,CustomPaint 跟随 child 大小。没有 child 时用 size 属性指定,或在父组件用 SizedBox 约束。
Q Canvas 上怎么画圆角矩形?
A 用
drawRRect(RRect.fromRectAndRadius(rect, radius), paint)。Q 签名板怎么保存为图片?
A 用
ui.PictureRecorder() 录制 Canvas 绘制,再 endRecording().toImage() 转为 ui.Image。Q shouldRepaint 写 true 有什么问题?
A 每次 build 都重绘,即使数据没变。简单组件无所谓,复杂图表会卡顿。
Q 为什么不在 paint 中用 Text 而用 drawParagraph?
A Canvas 不能直接画 Widget。文字需要 TextPainter/Paragraph 排版后用 paint(canvas, offset) 绘制。
📖 小节
- CustomPainter + paint() 完全控制绘制,shouldRepaint 控制重绘
- Canvas API 丰富:线条/矩形/圆形/路径/文字/阴影/渐变
- Listener/Pointer 事件实现手势绘制(签名板)
- RepaintBoundary 隔离重绘范围,shouldRepaint 精准判断避免浪费
- 折线图是 CustomPainter 最典型的实战应用
📝 作业
- 基础题(难度⭐):用 CustomPainter 画一个圆形进度条(0-100%),颜色从绿到红渐变。
- 进阶题(难度⭐⭐):实现销售折线图,包含网格线、渐变填充、数据点标记,点击数据点显示详情 Tooltip。
- 挑战题(难度⭐⭐⭐):实现完整的手写签名组件:触摸绘制 + 清除 + 保存为图片 + 笔画粗细随速度变化。