Flutter Canvas 自定义绘制实战:从入门到实战
Flutter 的
CustomPaint提供了强大的自定义绘制能力,是实现复杂 UI 效果的基础。本文从CustomPainter的基本使用出发,覆盖常见绘制场景和性能优化要点。
一、CustomPaint 基本使用
1.1 最简单的绘制示例
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
.. strokeWidth = 3
..style = PaintingStyle.stroke; // 描边,fill 为填充
// 在画布中心画一个圆
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
50,
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// 使用
class MyPage extends StatelessWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomPaint(
painter: MyPainter(),
size: const Size(200, 200), // 指定绘制区域大小
),
);
}
}
1.2 shouldRepaint 的重要性
shouldRepaint 决定是否需要重绘,正确实现可以大幅提升性能:
class MyPainter extends CustomPainter {
final Color color;
final double progress;
MyPainter(this.color, this.progress);
@override
void paint(Canvas canvas, Size size) {
// 绘制逻辑
}
@override
bool shouldRepaint(covariant MyPainter oldDelegate) {
// ✅ 只有颜色或进度变化时才重绘
return oldDelegate.color != color ||
(oldDelegate.progress - progress).abs() > 0.01;
}
}
二、常用绘制 API
2.1 绘制基本形状
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
// 画圆
canvas.drawCircle(const Offset(100, 100), 50, paint);
// 画矩形
canvas.drawRect(
Rect.fromLTWH(50, 50, 200, 100),
paint,
);
// 画圆角矩形
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(50, 200, 200, 100),
const Radius.circular(16),
),
paint,
);
// 画椭圆
canvas.drawOval(
Rect.fromLTWH(50, 350, 200, 100),
paint,
);
// 画路径
final path = Path()
..moveTo(50, 500)
..lineTo(150, 450)
..lineTo(250, 550)
..close(); // 闭合路径
canvas.drawPath(path, paint);
}
2.2 绘制文字
void paint(Canvas canvas, Size size) {
const text = 'Hello, Flutter!';
final textStyle = TextStyle(
color: Colors.black,
fontSize: 30,
fontWeight: FontWeight.bold,
);
final textSpan = TextSpan(text: text, style: textStyle);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout(minWidth: 0, maxWidth: size.width);
textPainter.paint(canvas, const Offset(50, 50));
}
2.3 绘制图片
Future<void> drawImage(Canvas canvas, Size size, ui.Image image) async {
final paint = Paint();
// 绘制完整图片
canvas.drawImage(image, const Offset(50, 50), paint);
// 绘制图片的一部分(九宫格、图片裁剪)
final src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final dst = Rect.fromLTWH(50, 50, 200, 200);
canvas.drawImageRect(image, src, dst, paint);
}
// 加载图片
Future<ui.Image> loadImage(String assetPath) async {
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return frame.image;
}
三、实战:绘制手写笔记(笔记应用核心功能)
笔记应用的核心功能之一是手写输入,使用 CustomPaint 实现:
3.1 定义笔画数据模型
class Stroke {
final List<Offset> points;
final Color color;
final double strokeWidth;
const Stroke(this.points, this.color, this.strokeWidth);
}
class NotebookPainter extends CustomPainter {
final List<Stroke> strokes;
final Stroke? currentStroke; // 当前正在绘制的笔画
NotebookPainter(this.strokes, this.currentStroke);
@override
void paint(Canvas canvas, Size size) {
// 绘制已完成的笔画
for (final stroke in strokes) {
_drawStroke(canvas, stroke);
}
// 绘制当前笔画
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
void _drawStroke(Canvas canvas, Stroke stroke) {
if (stroke.points.length < 2) return;
final paint = Paint()
..color = stroke.color
..strokeWidth = stroke.strokeWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final path = Path();
path.moveTo(stroke.points.first.dx, stroke.points.first.dy);
for (int i = 1; i < stroke.points.length; i++) {
path.lineTo(stroke.points[i].dx, stroke.points[i].dy);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant NotebookPainter oldDelegate) {
return true; // 手写场景,每次都需要重绘
}
}
3.2 GestureDetector 采集笔迹
class NotebookPage extends StatefulWidget {
const NotebookPage({super.key});
@override
State<NotebookPage> createState() => _NotebookPageState();
}
class _NotebookPageState extends State<NotebookPage> {
final List<Stroke> _strokes = [];
Stroke? _currentStroke;
@override
Widget build(BuildContext context) {
return GestureDetector(
// 笔触开始
onPanStart: (details) {
setState(() {
_currentStroke = Stroke(
[details.localPosition],
Colors.black,
3.0,
);
});
},
// 笔触移动
onPanUpdate: (details) {
setState(() {
_currentStroke!.points.add(details.localPosition);
});
},
// 笔触结束
onPanEnd: (_) {
setState(() {
_strokes.add(_currentStroke!);
_currentStroke = null;
});
},
child: CustomPaint(
painter: NotebookPainter(_strokes, _currentStroke),
size: Size.infinite, // 占满整个区域
),
);
}
}
3.3 性能优化:使用 PictureRecorder 缓存已完成的笔画
手写场景中,已完成笔画不需要每次重绘,使用 PictureRecorder 缓存:
class OptimizedNotebookPainter extends CustomPainter {
final Picture? cachedPicture;
final Stroke? currentStroke;
OptimizedNotebookPainter(this.cachedPicture, this.currentStroke);
static Future<Picture> recordStrokes(List<Stroke> strokes, Size size) async {
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
for (final stroke in strokes) {
_drawStroke(canvas, stroke);
}
return recorder.endRecording();
}
@override
void paint(Canvas canvas, Size size) {
// 直接绘制缓存的图片(不需要重新绘制每个笔画)
if (cachedPicture != null) {
canvas.drawPicture(cachedPicture!);
}
// 只绘制当前笔画
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
@override
bool shouldRepaint(covariant OptimizedNotebookPainter old) {
// 只有当前笔画变化时才重绘
return currentStroke != old.currentStroke;
}
}
四、实战:绘制图表(折线图)
class LineChartPainter extends CustomPainter {
final List<double> data;
final List<String> labels;
LineChartPainter(this.data, this.labels);
@override
void paint(Canvas canvas, Size size) {
if (data.isEmpty) return;
final paint = Paint()
..color = Colors.blue
..strokeWidth = 2
..style = PaintingStyle.stroke;
final path = Path();
final maxValue = data.reduce(math.max);
final minValue = data.reduce(math.min);
final range = maxValue - minValue;
// 计算点的坐标
for (int i = 0; i < data.length; i++) {
final x = (i / (data.length - 1)) * size.width;
final y = size.height -
((data[i] - minValue) / range) * size.height * 0.8;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
// 绘制圆点
final dotPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
for (int i = 0; i < data.length; i++) {
final x = (i / (data.length - 1)) * size.width;
final y = size.height -
((data[i] - minValue) / range) * size.height * 0.8;
canvas.drawCircle(Offset(x, y), 4, dotPaint);
}
}
@override
bool shouldRepaint(covariant LineChartPainter old) {
return !listEquals(old.data, data);
}
}
五、性能优化建议
5.1 使用 RepaintBoundary 隔离重绘区域
RepaintBoundary(
child: CustomPaint(
painter: MyPainter(),
size: size,
),
)
5.2 避免在 paint() 中创建对象
// ❌ 错误:每次重绘都创建新对象
@override
void paint(Canvas canvas, Size size) {
final paint = Paint(); // 每次都创建
// ...
}
// ✅ 正确:在构造函数中创建
class MyPainter extends CustomPainter {
final Paint _paint = Paint(); // 复用
@override
void paint(Canvas canvas, Size size) {
// 直接使用 _paint
}
}
5.3 使用 shouldRepaint 精确控制重绘
@override
bool shouldRepaint(covariant MyPainter old) {
// 精确判断:只有真正需要变化时才返回 true
return color != old.color || size != old.size;
}
六、常见问题 FAQ
Q:CustomPaint 和 Flutter 自带 Widget 如何选择?
A:优先使用 Flutter 自带的 Widget(性能更好、适配性更强)。只有在以下场景才使用 CustomPaint:
- 需要绘制复杂图形(手写、图表、特效)
- 需要实现特殊的动画效果
- 自带 Widget 无法满足 UI 需求
Q:如何在 Canvas 中实现缩放和平移?
A:使用 GestureDetector + Matrix4:
class _ZoomablePainterState extends State<ZoomablePainter> {
double _scale = 1.0;
Offset _offset = Offset.zero;
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = details.scale;
_offset += details.focalPointDelta;
});
},
child: CustomPaint(
painter: MyPainter(scale: _scale, offset: _offset),
size: Size.infinite,
),
);
}
}
七、参考资源
如果本文对你有帮助,欢迎点赞收藏。如有疑问,欢迎在评论区交流。