🎨 28 - 自定义绘制与Canvas

阶段四:高级UI · 第28课
✅ 验证通过

📖 核心概念

当内置Widget无法满足你的视觉需求时,CustomPaintCanvas是你的终极武器。本课将深入Flutter的自定义绘制体系——从基本图形绘制到复杂动画,从仪表盘到粒子系统,释放你的创造力。

🎨 CustomPaint基础

1. CustomPaint与CustomPainter

// CustomPaint基本结构
CustomPaint(
  size: Size(300, 300),  // 绘制区域大小
  painter: MyPainter(),  // 背景绘制器
  foregroundPainter: MyForegroundPainter(), // 前景绘制器
  child: Text('我在Canvas上面'), // 子Widget在painter之上
)

// 自定义Painter
class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 创建画笔
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    // 画圆
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      size.width / 3,
      paint,
    );

    // 画线
    canvas.drawLine(
      Offset(0, size.height / 2),
      Offset(size.width, size.height / 2),
      paint..color = Colors.red,
    );

    // 画矩形
    canvas.drawRect(
      Rect.fromCenter(
        center: Offset(size.width / 2, size.height / 2),
        width: size.width * 0.6,
        height: size.height * 0.4,
      ),
      paint..color = Colors.green,
    );
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) => false; // 静态图不重绘
}

2. 渐变与阴影

class GradientPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 线性渐变
    final linearGradient = const LinearGradient(
      colors: [Colors.purple, Colors.blue, Colors.cyan],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    );

    final rect = Offset.zero & size;
    final paint = Paint()
      ..shader = linearGradient.createShader(rect)
      ..style = PaintingStyle.fill;

    canvas.drawRect(rect, paint);

    // 径向渐变
    final radialPaint = Paint()
      ..shader = const RadialGradient(
        colors: [Colors.yellow, Colors.orange, Colors.red],
        center: Alignment(0.3, -0.3),
        radius: 0.8,
      ).createShader(rect);

    canvas.drawCircle(
      Offset(size.width * 0.65, size.height * 0.35),
      size.width * 0.25,
      radialPaint,
    );

    // 扫描渐变(Sweep)
    final sweepPaint = Paint()
      ..shader = const SweepGradient(
        colors: [Colors.red, Colors.yellow, Colors.green, Colors.blue, Colors.red],
        startAngle: 0,
        endAngle: 3.14 * 2,
      ).createShader(rect);

    canvas.drawCircle(
      Offset(size.width * 0.3, size.height * 0.7),
      size.width * 0.2,
      sweepPaint,
    );
  }

  @override
  bool shouldRepaint(covariant GradientPainter old) => false;
}

📊 实战:仪表盘组件

3. 动态仪表盘绘制

class GaugePainter extends CustomPainter {
  final double value;      // 0.0 - 1.0
  final Color activeColor;

  GaugePainter({required this.value, this.activeColor = Colors.blue});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 20;

    // 背景弧
    final bgPaint = Paint()
      ..color = Colors.grey[300]!
      ..style = PaintingStyle.stroke
      ..strokeWidth = 12
      ..strokeCap = StrokeCap.round;

    const startAngle = 0.75 * 3.14;  // 135度
    const sweepAngle = 1.5 * 3.14;   // 270度

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle, sweepAngle, false, bgPaint,
    );

    // 前景弧(带渐变)
    final fgPaint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 12
      ..strokeCap = StrokeCap.round
      ..shader = LinearGradient(
        colors: [Colors.green, activeColor, Colors.red],
      ).createShader(Rect.fromCircle(center: center, radius: radius));

    final valueAngle = startAngle + sweepAngle * value;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle, sweepAngle * value, false, fgPaint,
    );

    // 刻度线
    final tickPaint = Paint()
      ..color = Colors.grey[600]!
      ..strokeWidth = 2;

    for (int i = 0; i <= 10; i++) {
      final angle = startAngle + sweepAngle * (i / 10);
      final innerRadius = radius - 20;
      final outerRadius = radius - (i % 5 == 0 ? 30 : 25);

      canvas.drawLine(
        Offset(center.dx + innerRadius * cos(angle),
               center.dy + innerRadius * sin(angle)),
        Offset(center.dx + outerRadius * cos(angle),
               center.dy + outerRadius * sin(angle)),
        tickPaint..strokeWidth = i % 5 == 0 ? 3 : 1,
      );
    }

    // 中心数值
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${(value * 100).toInt()}',
        style: TextStyle(
          fontSize: 48, fontWeight: FontWeight.bold,
          color: activeColor,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();

    textPainter.paint(
      center - Offset(textPainter.width / 2, textPainter.height / 2),
    );
  }

  @override
  bool shouldRepaint(covariant GaugePainter old) => value != old.value;
}

// 使用动画驱动
class AnimatedGauge extends StatefulWidget {
  final double targetValue;

  @override
  State<AnimatedGauge> createState() => _AnimatedGaugeState();
}

class _AnimatedGaugeState extends State<AnimatedGauge>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1500),
    );
    _animation = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
    _controller.forward();
  }

  @override
  void didUpdateWidget(AnimatedGauge old) {
    super.didUpdateWidget(old);
    if (old.targetValue != widget.targetValue) {
      _controller.reset();
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, _) => CustomPaint(
        size: const Size(200, 200),
        painter: GaugePainter(value: widget.targetValue * _animation.value),
      ),
    );
  }
}

✨ 粒子系统

4. Canvas动画粒子效果

class ParticleSystemPainter extends CustomPainter {
  final List<Particle> particles;

  ParticleSystemPainter(this.particles);

  @override
  void paint(Canvas canvas, Size size) {
    for (final p in particles) {
      final paint = Paint()
        ..color = p.color.withOpacity(p.opacity)
        ..style = PaintingStyle.fill;

      canvas.drawCircle(p.position, p.radius, paint);

      // 拖尾效果
      if (p.trail.isNotEmpty) {
        final trailPaint = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = p.radius * 0.5
          ..strokeCap = StrokeCap.round;

        for (int i = 1; i < p.trail.length; i++) {
          trailPaint.color = p.color.withOpacity(
            p.opacity * (i / p.trail.length) * 0.5,
          );
          canvas.drawLine(p.trail[i - 1], p.trail[i], trailPaint);
        }
      }
    }
  }

  @override
  bool shouldRepaint(covariant ParticleSystemPainter old) => true;
}

class Particle {
  Offset position;
  Offset velocity;
  double radius;
  Color color;
  double opacity;
  List<Offset> trail;

  Particle({
    required this.position, required this.velocity,
    required this.radius, required this.color,
    this.opacity = 1.0, this.trail = const [],
  });

  void update() {
    trail = [...trail, position];
    if (trail.length > 8) trail = trail.sublist(trail.length - 8);

    position += velocity;
    velocity = Offset(velocity.dx * 0.99, velocity.dy + 0.1); // 重力
    opacity -= 0.01;
  }
}
✓ 基本图形:圆/矩形/线/弧/路径 ✓ 渐变效果:线性/径向/扫描渐变 ✓ 仪表盘:动画驱动+刻度+数值显示 ✓ 粒子系统:拖尾+重力+透明度衰减 ✓ 路径绘制:贝塞尔曲线+Path操作

🧮 实战:绘制环形图表

5. 带动画的环形图

环形图是数据可视化的常用组件。结合CustomPainter和AnimationController,创建带弹出动画的环形图。

class DonutChartPainter extends CustomPainter {
  final List<ChartSegment> segments;
  final double animationProgress; // 0.0 ~ 1.0

  DonutChartPainter({
    required this.segments,
    required this.animationProgress,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 20;
    final strokeWidth = radius * 0.35;
    final innerRadius = radius - strokeWidth;

    double startAngle = -3.14 / 2; // 从12点方向开始
    final total = segments.fold(0.0, (sum, s) => sum + s.value);

    for (final segment in segments) {
      final sweepAngle = (segment.value / total) * 2 * 3.14 * animationProgress;

      final paint = Paint()
        ..color = segment.color
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeWidth
        ..strokeCap = StrokeCap.butt;

      // 绘制弧线
      canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius - strokeWidth / 2),
        startAngle, sweepAngle, false, paint,
      );

      // 间隔线
      final gapAngle = 0.02; // 间隔角度
      startAngle += sweepAngle + gapAngle;
    }

    // 中心文字
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${(animationProgress * 100).toInt()}%',
        style: TextStyle(
          fontSize: radius * 0.35,
          fontWeight: FontWeight.bold,
          color: Colors.white,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();

    textPainter.paint(
      center - Offset(textPainter.width / 2, textPainter.height / 2),
    );
  }

  @override
  bool shouldRepaint(covariant DonutChartPainter old) =>
    animationProgress != old.animationProgress ||
    segments != old.segments;
}

class ChartSegment {
  final String label;
  final double value;
  final Color color;

  const ChartSegment({required this.label, required this.value, required this.color});
}

// 使用
final segments = [
  ChartSegment(label: '食品', value: 1200, color: Colors.red),
  ChartSegment(label: '交通', value: 800, color: Colors.blue),
  ChartSegment(label: '娱乐', value: 600, color: Colors.green),
  ChartSegment(label: '购物', value: 900, color: Colors.orange),
];

6. 绘制路径与贝塞尔曲线

Path是Canvas最强大的工具之一——贝塞尔曲线让你绘制任意光滑曲线。

class WavePainter extends CustomPainter {
  final double animationValue;

  WavePainter({required this.animationValue});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..style = PaintingStyle.fill;

    // 绘制多层波浪
    for (int wave = 0; wave < 3; wave++) {
      final path = Path();
      final baseY = size.height * (0.5 + wave * 0.12);
      final amplitude = 20.0 - wave * 5;
      final speed = 1.0 + wave * 0.3;

      path.moveTo(0, baseY);

      for (double x = 0; x <= size.width; x++) {
        final y = baseY +
          amplitude * sin((x / size.width * 2 * 3.14 * speed) +
            animationValue * 2 * 3.14 * speed);
        path.lineTo(x, y);
      }

      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
      path.close();

      paint.color = [
        Colors.blue.withOpacity(0.3),
        Colors.blue.withOpacity(0.2),
        Colors.blue.withOpacity(0.1),
      ][wave];

      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant WavePainter old) => true;
}

// 贝塞尔曲线示例:绘制光滑的图表线条
Path drawSmoothLine(List<Offset> points) {
  final path = Path();
  if (points.isEmpty) return path;

  path.moveTo(points.first.dx, points.first.dy);

  for (int i = 1; i < points.length - 1; i++) {
    final current = points[i];
    final next = points[i + 1];
    final controlX = (current.dx + next.dx) / 2;
    final controlY = (current.dy + next.dy) / 2;

    path.quadraticBezierTo(
      current.dx, current.dy,
      controlX, controlY,
    );
  }

  // 最后一个点
  path.lineTo(points.last.dx, points.last.dy);
  return path;
}

📝 练习

  1. 绘制一个心电图波形动画——使用正弦函数+噪声,模拟实时心电图
  2. 实现环形进度条——支持渐变色+动画+自定义宽度
  3. 创建签名板——手指/鼠标绘制路径,支持撤销和清除
  4. 绘制饼图/环形图——带标签、动画弹出效果和点击交互

🏆 成就解锁:画布大师

你已掌握自定义绘制与Canvas!

下一课预告:平台适配与响应式设计——让你的App在手机/平板/桌面都能完美展现!