Flutter 高级绘制与绘制动画

Flutter 高级绘制全解:CustomPainter 与 Canvas 的完整 API 体系、路径 Path 构建与贝塞尔曲线、渐变与阴影(Shader/LinearGradient/Shadow)、CustomPaint 的绘制顺序与 shouldRepaint 性能、绘制动画(AnimationController + CustomPainter 驱动图表/进度环/波浪)、以及图表与数据可视化的自绘实战。

开篇:当组件不够用,就让画笔接管

Flutter 的组件树解决 80% 界面,但总有些东西「组件画不出来」:

  • 折线图、面积图、仪表盘、波形图。
  • 自定义进度环、水波、粒子、涂鸦。
  • 复杂的渐变遮罩、不规则形状。

这些场景的答案就是 CustomPainter:拿到一块画布(Canvas),用画笔(Paint)与路径(Path)自己绘制每一帧。Flutter 的绘制能力对标原生 Canvas 2D,配合动画控制器能做出极其顺滑的自定义动效。

本文从画笔与画布的基础讲起,到路径与贝塞尔,到绘制动画与图表实战——让你从「不会画」到「敢自绘」。


一、CustomPainter 与 Canvas 基础

1.1 最小自绘结构

class HeartPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    // 用 path 画一颗心
    final path = Path()
      ..moveTo(size.width / 2, size.height * 0.3)
      ..quadraticBezierTo(size.width, size.height * 0.4, size.width / 2, size.height);
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant HeartPainter oldDelegate) =>
      oldDelegate.color != color;   // 数据变了才重绘
}

// 使用
CustomPaint(size: Size.square(200), painter: HeartPainter(color: Colors.red))

结构三要素:paint 方法(画什么) + shouldRepaint(何时重画) + CustomPaint 挂载。

1.2 Canvas 核心 API

Canvas 是一套「状态化绘图指令」,常用方法:

canvas.drawLine(offset1, offset2, paint);       // 线
canvas.drawRect(rect, paint);                  // 矩形
canvas.drawCircle(center, radius, paint);      // 圆
canvas.drawOval(rect, paint);                  // 椭圆
canvas.drawRRect(rrect, paint);                // 圆角矩形
canvas.drawPath(path, paint);                  // 路径
canvas.drawArc(rect, start, sweep, useCenter, paint); // 弧/扇形
canvas.drawTextSpan(span, offset);             // 文字
canvas.drawImage/imageRect(image, ...);        // 图片
canvas.save(); ... canvas.restore();           // 状态栈(变换隔离)

配合 canvas.translate/rotate/scale 做变换,绘制「坐标系思维」越清晰越好写。

二、Paint:画笔的完整配置

2.1 风格与粗细

final paint = Paint()
  ..color = Colors.blue
  ..style = PaintingStyle.fill      // 填充
  // ..style = PaintingStyle.stroke // 描边
  ..strokeWidth = 4
  ..strokeCap = StrokeCap.round     // 端点圆头
  ..strokeJoin = StrokeJoin.round   // 拐角圆角
  ..strokeMiterLimit = 4;

fill vs stroke 决定「是形状还是轮廓」,图表线用 stroke,面积图用 fill + 路径闭合。

2.2 渐变与遮罩

// 线性渐变
final linear = Paint()
  ..shader = LinearGradient(
    begin: Alignment.topLeft, end: Alignment.bottomRight,
    colors: [Colors.cyan, Colors.blue],
  ).createShader(rect);

// 径向渐变(球体/光晕)
final radial = Paint()
  ..shader = RadialGradient(colors: [Colors.white, Colors.transparent])
    .createShader(rect);

// 阴影
canvas.drawShadow(path, color, elevation, transparentOccluder: true);
// 或 Paint()..maskFilter = MaskFilter.blur(BlurStyle.normal, sigma)

渐变 == shader:createShader(rect) 决定渐变作用域,矩形写错渐变就「拉不出来」。阴影用 MaskFilter.blur 会触发离屏渲染,大范围慎用(性能)。

三、Path:绘制真正的形状

3.1 路径构建 API

Path 是所有复杂形状的骨架:

final path = Path()
  ..moveTo(0, 0)                 // 起点
  ..lineTo(100, 0)               // 直线
  ..arcTo(rrect, 0, 1.57, false) // 圆弧
  ..quadraticBezierTo(cx, cy, x, y)  // 二次贝塞尔(一个控制点)
  ..cubicTo(c1x, c1y, c2x, c2y, x, y) // 三次贝塞尔(两个控制点)
  ..close();                     // 闭合
// 判断/变换
path.contains(point);            // 点是否在形状内(命中测试可自绘)
path.getBounds();                // 边界
path.transform(matrix4);         // 变换

3.2 贝塞尔曲线的直觉

贝塞尔是曲线绘制核心,理解控制点的「引力」:

  • 二次贝塞尔:起点、一个控制点、终点——控制点「拽」曲线。
  • 三次贝塞尔:两个控制点,是 Flutter 里最常用的(波浪、心形、图表平滑线)。
// 平滑波形: 分段三次贝塞尔拼出正弦/波浪
// 每个点 (x_i, y_i) 用前点切线 + 后点切线作控制点
// 工程套路: 用"Catmull-Rom → 贝塞尔"转换,得到平滑折线图

画曲线类图形(心电图、波浪、走势图),贝塞尔是唯一正解——直线段连起来是锯齿。

四、shouldRepaint 与绘制性能

4.1 重绘的触发规则

CustomPainter 的 shouldRepaint 决定数据变化时是否重新绘制:

@override
bool shouldRepaint(ChartPainter old) =>
    old.data != data || old.colors != colors;

别写死返回 true——那样每帧全量重绘,浪费。数据不变就不该重画。但「动画中的每一帧」本来就要重画,此时把「动画进度」作为重绘依据即可。

4.2 高性能绘制原则

# 1) 不要在 paint 里做耗时计算/对象创建
#    把能缓存的 Path/Paint 提到构造时或静态字段
# 2) 只绘制需要刷新的区域(canvas.clipRect)
# 3) 阴影/模糊少用,它们触发离屏渲染
# 4) 图片/大纹理缓存为 ui.Image,别每次解码
# 5) 小变动用 RepaintBoundary 隔离重绘范围
# 6) 动画驱动用 AnimationController + AnimatedBuilder,
#    只重建 CustomPaint 层,别 setState 整棵树

RepaintBoundary 尤其关键:把 CustomPaint 包进 RepaintBoundary,滚动列表里它独立一层,不会整列表重绘。

五、绘制动画:让画布动起来

5.1 进度环动画(最经典)

class ProgressRing extends StatefulWidget {
  @override
  State<ProgressRing> createState() => _ProgressRingState();
}

class _ProgressRingState extends State<ProgressRing>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(vsync: this, duration: const Duration(seconds: 1));

  @override
  void initState() { super.initState(); _controller.forward(); }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (_, __) => CustomPaint(
        painter: _RingPainter(_controller.value),
        size: const Size.square(120),
      ),
    );
  }
}

class _RingPainter extends CustomPainter {
  _RingPainter(this.progress);
  final double progress;                       // 0→1

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    // 底环
    canvas.drawCircle(rect.center, rect.width/2, _bgPaint);
    // 进度弧(sweepAngle = progress * 2π)
    canvas.drawArc(rect.deflate(8), -pi/2, progress * 2 * pi, false, _fgPaint);
    // 端点圆点
    final end = _pointOnCircle(rect.center, rect.width/2 - 8, -pi/2 + progress*2*pi);
    canvas.drawCircle(end, 6, _dotPaint);
  }

  @override
  bool shouldRepaint(_RingPainter old) => old.progress != progress;
}

动画模式:AnimationController.value 每帧变化 → AnimatedBuilder 触发重建 → 新 value 传给 painter → shouldRepaint 返回 true 重绘。这是「绘制动画」的通用骨架。

5.2 波浪与水波动画

波浪是绘制动画的代表作:多段贝塞尔 + 相位偏移 + 时间驱动,形成流动感:

// 波峰: 用 y = A * sin(x*freq + phase),phase 随时间前进
// 每帧 phase 增加,波形整体平移,视觉上就是水在流动
// 两层波(不同相位/透明度)叠加 → 更真实的水面
// 上层矩形填充颜色 → 盖上「水面以下」的区域

「蒙版 + 渐变 + 双层波」组合,能做加载水球、泳池水位、音乐均衡器。

六、图表与数据可视化实战

6.1 折线图与面积图

class LineChartPainter extends CustomPainter {
  LineChartPainter({required this.points, required this.showArea});
  final List<Offset> points;
  final bool showArea;

  @override
  void paint(Canvas canvas, Size size) {
    // 折线
    final linePath = Path()..moveTo(points.first.dx, points.first.dy);
    for (final p in points.skip(1)) { linePath.lineTo(p.dx, p.dy); }
    canvas.drawPath(linePath, _strokePaint);
    // 面积: 闭合到底边
    if (showArea) {
      final areaPath = Path.from(linePath)
        ..lineTo(size.width, size.height)
        ..lineTo(0, size.height)
        ..close();
      canvas.drawPath(areaPath, _fillPaint);   // 渐变填充
    }
  }
  // 缩放: 外部先把数据归一化到 [0,1] 再映射到 size
}

自绘图表的关键:数据归一化(min-max 映射到画布坐标)+ Y 轴反转(画布 Y 向下,数值大要画在上方)。

6.2 仪表盘与雷达图

  • 仪表盘:drawArc 画 0~270° 刻度弧 + 指针(旋转 canvas 画矩形)。
  • 雷达图:正多边形网格 + 顶点连线 + 多边形填充。
  • 热力图:双层 for 循环 drawRect,颜色按值取渐变插值(Color.lerp)。

工程选型提醒:数据可视化有 fl_chart 等成熟库,80% 场景别自绘(维护成本低得多);自绘留给「高度定制 / 追求性能 / 炫酷动效」。

FAQ

Q:shouldRepaint 返回 false 会怎样?
A:数据变化不会重绘,界面不变。所以「动画帧」必须 true,「静态数据」由数据比较决定——写对它是性能关键。

Q:自定义绘制性能差怎么办?
A:按 4.2 排查:缓存 Path/Paint、clipRect 裁剪、少用阴影、RepaintBoundary 隔离、别在 paint 里 new 对象。

Q:折线图怎么平滑?
A:用三次贝塞尔拼接平滑曲线,不要直线段连接。数据点先用 Catmull-Rom 平滑再转贝塞尔。

Q:渐变画不出来?
A:八成是 createShader(rect) 的 rect 与绘制区域不匹配,或 Alignment 方向写反。先打印 rect 再定位。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. Flutter Design Tokens 与自适应主题工程
  2. Flutter 错误处理与可靠性工程
  3. Flutter Firebase 后端集成实战