玩转 Flutter 自定义 Painter:从零打造丝滑的仪表盘动效与可视化图表

欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。

在 Flutter 开发中,原生组件往往难以满足个性化的视觉需求 ------ 比如电商 APP 的销量仪表盘、金融 APP 的收益走势图、健身 APP 的运动数据环形图等。而CustomPainter正是解锁 Flutter 视觉定制的 "金钥匙",它允许我们直接操控画布,绘制出任意形态的 UI 元素,且性能远超堆叠的原生组件。本文将从基础原理到实战进阶,手把手教你用CustomPainter打造兼具美感与性能的仪表盘动效,同时解锁可视化图表的核心绘制逻辑。

一、CustomPainter 核心原理:画布的 "魔法棒"

1. 核心概念拆解

CustomPainter的本质是通过Canvas(画布)和Paint(画笔)完成绘制,核心组件包括:

  • CustomPainter :抽象类,需实现paint(Canvas canvas, Size size)shouldRepaint方法;
  • Canvas:绘制画布,提供点、线、圆、路径、文本等绘制 API;
  • Paint:画笔,控制颜色、粗细、填充方式、抗锯齿等属性;
  • Path:路径,组合多个绘制指令,实现复杂图形(如弧形、贝塞尔曲线);
  • Animation:结合动画控制器,让静态绘制变为动态动效。

2. 性能优势

相比于用Container+Transform+Opacity等组件堆叠实现复杂图形,CustomPainter有两大核心优势:

  • 减少 Widget 树层级 :单个CustomPaint组件替代数十个原生组件,降低渲染开销;
  • 精准控制重绘 :通过shouldRepaint判断是否需要重绘,避免无效刷新;
  • 硬件加速:绘制操作基于 Skia 引擎,默认开启硬件加速,帧率更稳定。

二、实战 1:打造动态仪表盘组件

我们先实现一个基础的仪表盘组件,包含 "背景环 + 进度环 + 刻度 + 指针 + 数值",并添加进度动画和指针旋转动效。

1. 定义仪表盘配置类

封装可配置参数,让组件具备高度复用性:

dart

复制代码
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

/// 仪表盘配置类
class DashboardConfig {
  // 最大数值
  final double maxValue;
  // 起始角度(弧度),默认140度(钟表10点方向)
  final double startAngle;
  // 结束角度(弧度),默认220度(钟表2点方向)
  final double endAngle;
  // 背景环颜色
  final Color bgColor;
  // 进度环颜色
  final Color progressColor;
  // 刻度颜色
  final Color scaleColor;
  // 指针颜色
  final Color pointerColor;
  // 数值文本样式
  final TextStyle textStyle;
  // 动画时长
  final Duration animationDuration;

  const DashboardConfig({
    this.maxValue = 100,
    this.startAngle = 2.443, // 140° * π/180
    this.endAngle = 3.839, // 220° * π/180
    this.bgColor = const Color(0xFFE0E0E0),
    this.progressColor = const Color(0xFF4CAF50),
    this.scaleColor = const Color(0xFF9E9E9E),
    this.pointerColor = const Color(0xFFF44336),
    this.textStyle = const TextStyle(
      fontSize: 24,
      fontWeight: FontWeight.bold,
      color: Colors.black87,
    ),
    this.animationDuration = const Duration(milliseconds: 1500),
  });
}

2. 实现仪表盘 Painter

dart

复制代码
/// 仪表盘绘制器
class DashboardPainter extends CustomPainter {
  // 当前进度值
  final double value;
  // 配置参数
  final DashboardConfig config;
  // 动画值(控制进度和指针旋转)
  final Animation<double> animation;

  DashboardPainter({
    required this.value,
    required this.config,
    required this.animation,
  }) : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    // 画布中心坐标
    final center = Offset(size.width / 2, size.height / 2);
    // 仪表盘半径(取宽高中较小值,适配不同尺寸)
    final radius = (size.width < size.height ? size.width : size.height) / 2 * 0.8;

    // 1. 绘制背景环
    _drawBackgroundRing(canvas, center, radius);

    // 2. 绘制刻度
    _drawScales(canvas, center, radius);

    // 3. 绘制进度环(结合动画值)
    final progress = value / config.maxValue * animation.value;
    _drawProgressRing(canvas, center, radius, progress);

    // 4. 绘制指针(结合动画值)
    final angle = config.startAngle + (config.endAngle - config.startAngle) * progress;
    _drawPointer(canvas, center, radius, angle);

    // 5. 绘制数值文本
    _drawValueText(canvas, center, progress);
  }

  /// 绘制背景环
  void _drawBackgroundRing(Canvas canvas, Offset center, double radius) {
    final paint = Paint()
      ..color = config.bgColor
      ..strokeWidth = radius * 0.1 // 环宽度为半径的10%
      ..strokeCap = StrokeCap.round // 圆角端点
      ..style = PaintingStyle.stroke; // 描边模式

    // 绘制圆弧
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      config.startAngle,
      config.endAngle - config.startAngle,
      false, // 不填充
      paint,
    );
  }

  /// 绘制刻度
  void _drawScales(Canvas canvas, Offset center, double radius) {
    final paint = Paint()
      ..color = config.scaleColor
      ..strokeWidth = 1.5
      ..style = PaintingStyle.stroke;

    // 总刻度数(20个)
    const scaleCount = 20;
    final angleStep = (config.endAngle - config.startAngle) / scaleCount;

    for (int i = 0; i <= scaleCount; i++) {
      final angle = config.startAngle + angleStep * i;
      // 刻度起始点(外环)
      final startX = center.dx + radius * cos(angle);
      final startY = center.dy + radius * sin(angle);
      // 刻度结束点(内环,长刻度/短刻度区分)
      final endRadius = i % 5 == 0 ? radius * 0.85 : radius * 0.9; // 每5个刻度加长
      final endX = center.dx + endRadius * cos(angle);
      final endY = center.dy + endRadius * sin(angle);

      // 绘制单条刻度
      canvas.drawLine(
        Offset(startX, startY),
        Offset(endX, endY),
        paint,
      );
    }
  }

  /// 绘制进度环
  void _drawProgressRing(Canvas canvas, Offset center, double radius, double progress) {
    final paint = Paint()
      ..color = config.progressColor
      ..strokeWidth = radius * 0.1
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..shader = LinearGradient(
        colors: [config.progressColor.withOpacity(0.6), config.progressColor],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ).createShader(Rect.fromCircle(center: center, radius: radius)); // 渐变效果

    // 进度对应的弧度
    final sweepAngle = (config.endAngle - config.startAngle) * progress;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      config.startAngle,
      sweepAngle,
      false,
      paint,
    );
  }

  /// 绘制指针
  void _drawPointer(Canvas canvas, Offset center, double radius, double angle) {
    final paint = Paint()
      ..color = config.pointerColor
      ..style = PaintingStyle.fill;

    // 指针路径(三角形)
    final path = Path()
      ..moveTo(
        center.dx + radius * 0.7 * cos(angle),
        center.dy + radius * 0.7 * sin(angle),
      ) // 指针尖端
      ..lineTo(
        center.dx - radius * 0.1 * cos(angle - 0.5),
        center.dy - radius * 0.1 * sin(angle - 0.5),
      ) // 指针左底点
      ..lineTo(
        center.dx - radius * 0.1 * cos(angle + 0.5),
        center.dy - radius * 0.1 * sin(angle + 0.5),
      ) // 指针右底点
      ..close();

    // 绘制指针
    canvas.drawPath(path, paint);

    // 绘制指针中心圆点
    canvas.drawCircle(
      center,
      radius * 0.05,
      paint..color = config.pointerColor.withOpacity(0.9),
    );
  }

  /// 绘制数值文本
  void _drawValueText(Canvas canvas, Offset center, double progress) {
    final text = '${(progress * config.maxValue).toStringAsFixed(0)}';
    final textSpan = TextSpan(
      text: text,
      style: config.textStyle,
    );
    final textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
      textAlign: TextAlign.center,
    );

    // 布局文本
    textPainter.layout();
    // 文本绘制位置(居中)
    final textOffset = Offset(
      center.dx - textPainter.width / 2,
      center.dy + radius * 0.2, // 文本在指针下方
    );

    textPainter.paint(canvas, textOffset);
  }

  @override
  bool shouldRepaint(covariant DashboardPainter oldDelegate) {
    // 仅当值或配置变化时重绘
    return oldDelegate.value != value || oldDelegate.config != config;
  }
}

3. 封装仪表盘 Widget(带动画)

dart

复制代码
/// 仪表盘组件
class DashboardWidget extends StatefulWidget {
  // 当前值
  final double value;
  // 配置
  final DashboardConfig config;
  // 宽度
  final double width;
  // 高度
  final double height;

  const DashboardWidget({
    super.key,
    required this.value,
    this.config = const DashboardConfig(),
    this.width = 200,
    this.height = 200,
  });

  @override
  State<DashboardWidget> createState() => _DashboardWidgetState();
}

class _DashboardWidgetState extends State<DashboardWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    // 初始化动画控制器
    _animationController = AnimationController(
      vsync: this,
      duration: widget.config.animationDuration,
    );

    // 动画曲线(先慢后快再慢,更自然)
    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOutCubic,
    );

    // 延迟执行动画(避免页面初始化时卡顿)
    SchedulerBinding.instance.addPostFrameCallback((_) {
      _animationController.forward();
    });
  }

  @override
  void didUpdateWidget(covariant DashboardWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 数值变化时重新执行动画
    if (oldWidget.value != widget.value) {
      _animationController.reset();
      _animationController.forward();
    }
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(widget.width, widget.height),
      painter: DashboardPainter(
        value: widget.value,
        config: widget.config,
        animation: _animation,
      ),
    );
  }
}

4. 代码核心解析

(1)角度与弧度转换

仪表盘的弧形绘制基于弧度(而非角度),公式:弧度 = 角度 × π / 180,比如 140° 对应2.443弧度,220° 对应3.839弧度,确保弧形从 10 点到 2 点方向,符合仪表盘视觉习惯。

(2)动画与重绘联动

通过super(repaint: animation)将动画控制器与 Painter 绑定,动画值变化时自动触发重绘;同时在shouldRepaint中精准控制重绘条件,避免无效刷新。

(3)渐变与样式优化

进度环添加线性渐变LinearGradient,让视觉效果更丰富;刻度区分长刻度和短刻度(每 5 个刻度加长),提升可读性;指针采用三角形路径 + 中心圆点,模拟真实仪表盘指针形态。

(4)适配性设计

仪表盘半径基于宽高中较小值计算,确保在不同尺寸下都能完整显示;数值文本居中绘制,且位置在指针下方,符合视觉流程。

三、实战 2:仪表盘组件使用与扩展

1. 基础使用示例

dart

复制代码
class DashboardDemoPage extends StatefulWidget {
  const DashboardDemoPage({super.key});

  @override
  State<DashboardDemoPage> createState() => _DashboardDemoPageState();
}

class _DashboardDemoPageState extends State<DashboardDemoPage> {
  double _currentValue = 75; // 初始值

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("自定义仪表盘示例"),
        backgroundColor: Colors.blueAccent,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 基础仪表盘
            DashboardWidget(
              value: _currentValue,
              config: const DashboardConfig(
                maxValue: 100,
                progressColor: Colors.blueAccent,
                pointerColor: Colors.redAccent,
                textStyle: TextStyle(fontSize: 28, color: Colors.blueAccent),
              ),
              width: 250,
              height: 250,
            ),
            const SizedBox(height: 40),
            // 数值调节滑块
            Slider(
              value: _currentValue,
              min: 0,
              max: 100,
              onChanged: (value) {
                setState(() {
                  _currentValue = value;
                });
              },
              activeColor: Colors.blueAccent,
            ),
            Text("当前值:${_currentValue.toStringAsFixed(0)}"),
          ],
        ),
      ),
    );
  }
}

2. 扩展:多维度仪表盘(温度 + 湿度 + 风速)

dart

复制代码
class MultiDashboardPage extends StatelessWidget {
  const MultiDashboardPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("多维度仪表盘")),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            // 温度仪表盘(0-50℃)
            Column(
              children: [
                const Text("温度"),
                DashboardWidget(
                  value: 28,
                  config: DashboardConfig(
                    maxValue: 50,
                    progressColor: Colors.red,
                    startAngle: 2.618, // 150°
                    endAngle: 3.665, // 210°
                  ),
                ),
              ],
            ),
            // 湿度仪表盘(0-100%)
            Column(
              children: [
                const Text("湿度"),
                DashboardWidget(
                  value: 65,
                  config: DashboardConfig(
                    maxValue: 100,
                    progressColor: Colors.blue,
                  ),
                ),
              ],
            ),
            // 风速仪表盘(0-20m/s)
            Column(
              children: [
                const Text("风速"),
                DashboardWidget(
                  value: 8,
                  config: DashboardConfig(
                    maxValue: 20,
                    progressColor: Colors.green,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

四、进阶优化与扩展

1. 性能优化技巧

  • 减少绘制计算 :将固定不变的绘制参数(如中心坐标、半径)在paint方法开头计算一次,避免重复计算;

  • 使用缓存画布 :对于静态部分(如背景环、刻度),可绘制到PictureRecorder中缓存,减少重复绘制:

    dart

    复制代码
    // 缓存静态绘制内容
    late Picture _staticPicture;
    void _cacheStaticContent(Size size) {
      final recorder = PictureRecorder();
      final canvas = Canvas(recorder);
      // 绘制背景环、刻度等静态内容
      _staticPicture = recorder.endRecording();
    }
    
    // 在paint中绘制缓存内容
    canvas.drawPicture(_staticPicture);
  • 抗锯齿优化 :在Paint中添加isAntiAlias: true,提升绘制清晰度(默认开启,但显式声明更稳妥)。

2. 功能扩展

  • 添加单位文本:在数值文本下方添加单位(如℃、%、m/s),增强可读性;
  • 分段进度色:根据进度值切换进度环颜色(如 0-50 绿色、50-80 黄色、80 + 红色);
  • 3D 效果:通过绘制多层弧形叠加,模拟 3D 仪表盘效果;
  • 触摸交互 :结合GestureDetector,支持点击 / 滑动修改仪表盘数值。

3. 适配暗黑模式

扩展DashboardConfig,添加暗黑模式配置:

dart

复制代码
final Color darkBgColor;
final Color darkProgressColor;

// 在绘制时根据主题切换
color: Theme.of(context).brightness == Brightness.dark
    ? config.darkBgColor
    : config.bgColor,

五、CustomPainter 常见避坑指南

问题 原因 解决方案
绘制内容超出画布 未考虑画布尺寸,绘制坐标超出范围 基于Size计算绘制范围,使用clamp限制坐标;
动画卡顿 paint中执行耗时计算 将耗时计算移到initState/didUpdateWidget中缓存;
文字绘制不居中 未正确计算文本宽高 使用TextPainter.layout()获取文本尺寸,再计算偏移;
重绘频繁 shouldRepaint返回 true 过于频繁 精准判断重绘条件,仅当关键参数变化时返回 true;
不同设备显示不一致 硬编码像素值 基于画布尺寸的比例计算绘制参数(如半径 = size.width*0.4)。

六、总结

本文从CustomPainter核心原理出发,完整实现了一款可定制、带动画的仪表盘组件,核心亮点在于:

  1. 模块化设计:将配置、绘制、动画拆分为独立模块,代码结构清晰,易于维护;
  2. 高性能绘制:通过精准的重绘控制、缓存计算结果,保证 60fps 流畅动效;
  3. 高度可定制:支持修改颜色、角度、动画时长等参数,适配不同业务场景;
  4. 视觉优化:加入渐变、刻度区分、指针造型等细节,提升视觉体验。

CustomPainter的潜力远不止仪表盘 ------ 基于本文的核心逻辑,你可以轻松扩展出折线图、柱状图、饼图、自定义按钮、动效图标等任意视觉元素。相比于第三方图表库,自定义绘制的组件更轻量、更贴合业务需求,且能完全掌控性能和样式。

最后,附上完整示例代码仓库(示例):https://github.com/xxx/flutter_custom_dashboard,欢迎大家 Star、Fork,也欢迎在评论区交流CustomPainter的实战技巧和扩展思路!

相关推荐
L、2185 小时前
Flutter + OpenHarmony + AI:打造智能本地大模型驱动的跨端应用(AI 时代新范式)
人工智能·flutter·华为·智能手机·harmonyos
利剑 -~5 小时前
设计java高并安全类
java·开发语言
CoderYanger5 小时前
D.二分查找-基础——744. 寻找比目标字母大的最小字母
java·开发语言·数据结构·算法·leetcode·职场和发展
柯南二号5 小时前
【后端】【Java】一文详解Spring Boot 统一日志与链路追踪实践
java·开发语言·数据库
weixin_307779135 小时前
Jenkins Pipeline: Basic Steps 插件详解
开发语言·ci/cd·自动化·jenkins·etl
小白|5 小时前
Flutter 与 OpenHarmony 深度集成:实现跨设备传感器数据协同监测系统
flutter·wpf
柯南二号5 小时前
【后端】【Java】RESTful书面应该如何写
java·开发语言·restful
切糕师学AI5 小时前
如何用 VS Code + C# Dev Kit 创建类库项目并在主项目中引用它?
开发语言·c#
JIngJaneIL5 小时前
基于Java+ vueOA工程项目管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端