Flutter: 自定义绘制与 CustomPainter

当标准组件不够用,Canvas 就是你的画布——任何 UI 都可以一笔一划画出来。

📋 前置知识:需要先掌握以下内容

1. 你将学到


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 体系

100%
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 上怎么画圆角矩形?
AdrawRRect(RRect.fromRectAndRadius(rect, radius), paint)
Q 签名板怎么保存为图片?
Aui.PictureRecorder() 录制 Canvas 绘制,再 endRecording().toImage() 转为 ui.Image。
Q shouldRepaint 写 true 有什么问题?
A 每次 build 都重绘,即使数据没变。简单组件无所谓,复杂图表会卡顿。
Q 为什么不在 paint 中用 Text 而用 drawParagraph?
A Canvas 不能直接画 Widget。文字需要 TextPainter/Paragraph 排版后用 paint(canvas, offset) 绘制。

📖 小节


📝 作业

  1. 基础题(难度⭐):用 CustomPainter 画一个圆形进度条(0-100%),颜色从绿到红渐变。
  2. 进阶题(难度⭐⭐):实现销售折线图,包含网格线、渐变填充、数据点标记,点击数据点显示详情 Tooltip。
  3. 挑战题(难度⭐⭐⭐):实现完整的手写签名组件:触摸绘制 + 清除 + 保存为图片 + 笔画粗细随速度变化。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏