Flutter之旅 -- 自定义组件

本篇文章主要介绍以下几个内容:

  • Flutter 自定义组件
  • 与 Android/iOS 原生自定义组件对比
  • 从零到一实现一个图表 ChartBar 组件示例
Flutter之旅

在 Flutter 开发中,虽然官方提供了丰富的 Widget 组件,但在实际项目中,我们经常会遇到需要自定义绘制的场景。
本文将以一个功能完整的 ChartBar 图表组件为例,从 0 到 1 展示 Flutter 自定义 View 的实现方法。

1. Flutter 自定义组件

1.1 场景

在以下场景中,通常需要自定义组件:

  • 复杂图形绘制:如图表、仪表盘、自定义进度条等;
  • 特殊交互效果:现有 Widget 无法满足的交互需求;
  • 性能优化:减少 Widget 树层级,提升渲染性能;
  • 品牌定制:实现独特的 UI 设计风格;
  • 动画效果:复杂的自定义动画实现。

1.2 实现方式

Flutter 提供了多种自定义绘制的方式:

  1. CustomPainter + CustomPaint:最常用的方式,适合复杂绘制;
  2. RenderObject:更底层的实现,性能更好但复杂度更高;
  3. Canvas 直接绘制:在特定场景下使用。

本文主要讲解 CustomPainter 的实现方式。

2. Flutter vs 原生自定义组件对比

特性 Flutter Android View Android Compose iOS
绘制API Canvas + Paint Canvas + Paint DrawScope + DrawContext Core Graphics
坐标系统 左上角原点 左上角原点 左上角原点 左下角原点
性能 Impeller/Skia引擎,高性能 硬件加速 硬件加速 Core Animation
跨平台 一套代码多平台 仅Android 仅Android 仅iOS
学习成本 中等 中等 较低 较高
开发方式 声明式 命令式 声明式 命令式
状态管理 StatefulWidget 手动管理 自动重组 手动管理

2.1 详细对比分析

以下通过一个简单的自定义圆形进度条示例,展示各平台的自定义绘制实现方式。
实现效果如下:

圆形进度条

  • Flutter CustomPainter
import 'dart:math' as math;

// 自定义圆形进度条组件
class CircularProgressPainter extends CustomPainter {
  final double progress; // 进度值 0.0-1.0
  
  CircularProgressPainter(this.progress);
  
  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;
    
    // 绘制背景圆环
    final backgroundPaint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 8.0
      ..style = PaintingStyle.stroke;
    canvas.drawCircle(center, radius, backgroundPaint);
    
    // 绘制进度圆弧
    final progressPaint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 8.0
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2, // 从顶部开始
      2 * math.pi * progress, // 根据进度计算弧度
      false,
      progressPaint,
    );
  }
  
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

// 使用方式
CustomPaint(
  size: Size(100, 100),
  painter: CircularProgressPainter(0.7), // 70%进度
)
  • Android 传统 View
// 自定义圆形进度条View
class CircularProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    
    private var progress = 0f // 进度值 0.0-1.0
    
    // 背景圆环画笔
    private val backgroundPaint = Paint().apply {
        color = Color.LTGRAY
        strokeWidth = 24f
        style = Paint.Style.STROKE
        isAntiAlias = true
    }
    
    // 进度圆弧画笔
    private val progressPaint = Paint().apply {
        color = Color.BLUE
        strokeWidth = 24f
        style = Paint.Style.STROKE
        strokeCap = Paint.Cap.ROUND
        isAntiAlias = true
    }
    
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas?.let {
            val centerX = width / 2f
            val centerY = height / 2f
            val radius = (width.coerceAtMost(height) / 2f) - 30f
            
            // 绘制背景圆环
            it.drawCircle(centerX, centerY, radius, backgroundPaint)
            
            // 绘制进度圆弧
            val rect = RectF(
                centerX - radius, centerY - radius,
                centerX + radius, centerY + radius
            )
            it.drawArc(rect, -90f, 360f * progress, false, progressPaint)
        }
    }
    
    // 设置进度的方法
    fun setProgress(progress: Float) {
        this.progress = progress.coerceIn(0f, 1f)
        invalidate() // 触发重绘
    }
}
  • Android Compose
// 自定义圆形进度条Composable
@Composable
fun CircularProgressIndicator(
    progress: Float, // 进度值 0.0-1.0
    modifier: Modifier = Modifier,
    size: Dp = 100.dp
) {
    Canvas(modifier = modifier.size(size)) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.width / 2 - 20.dp.toPx()
        
        // 绘制背景圆环
        drawCircle(
            color = Color.LightGray,
            radius = radius,
            center = center,
            style = Stroke(width = 8.dp.toPx())
        )
        
        // 绘制进度圆弧
        drawArc(
            color = Color.Blue,
            startAngle = -90f, // 从顶部开始
            sweepAngle = 360f * progress, // 根据进度计算角度
            useCenter = false,
            topLeft = Offset(center.x - radius, center.y - radius),
            size = Size(radius * 2, radius * 2),
            style = Stroke(
                width = 8.dp.toPx(),
                cap = StrokeCap.Round
            )
        )
    }
}

// 使用方式
CircularProgressIndicator(progress = 0.7f) // 70%进度
  • iOS Core Graphics
// 自定义圆形进度条UIView
class CircularProgressView: UIView {
    
    var progress: CGFloat = 0.0 {
        didSet {
            setNeedsDisplay() // 触发重绘
        }
    }
    
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        let radius = min(rect.width, rect.height) / 2 - 10
        
        // 绘制背景圆环
        context.setStrokeColor(UIColor.lightGray.cgColor)
        context.setLineWidth(8.0)
        context.addArc(
            center: center,
            radius: radius,
            startAngle: 0,
            endAngle: CGFloat.pi * 2,
            clockwise: false
        )
        context.strokePath()
        
        // 绘制进度圆弧
        context.setStrokeColor(UIColor.blue.cgColor)
        context.setLineWidth(8.0)
        context.setLineCap(.round)
        
        let startAngle = -CGFloat.pi / 2 // 从顶部开始
        let endAngle = startAngle + (CGFloat.pi * 2 * progress)
        
        context.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: false
        )
        context.strokePath()
    }
}

// 使用方式
let progressView = CircularProgressView()
progressView.progress = 0.7 // 设置70%进度

代码对比总结

平台 代码复杂度 状态管理 重绘机制 类型安全
Flutter 中等,声明式清晰 自动管理 shouldRepaint 控制 Dart 类型安全
Android View 较复杂,需手动管理 手动 invalidate() onDraw 重写 Kotlin 类型安全
Android Compose 简洁,声明式 自动重组 状态变化自动重绘 Kotlin 类型安全
iOS 复杂,C风格API 手动 setNeedsDisplay() draw 方法重写 Swift 类型安全

2.2 各平台特色对比

Flutter 的优势

  • 一致性:一套代码,多平台完全一致的渲染效果;
  • 热重载:开发效率极高,实时预览效果;
  • 丰富的动画 API:内置强大的动画系统;;
  • 声明式 UI:代码更简洁,状态管理更清晰;
  • Impeller 渲染引擎:新一代高性能 2D 图形渲染引擎(iOS默认,Android可选)。

Android Compose 的优势

  • 现代化:Google 最新推荐的 UI 框架;
  • 声明式:类似 Flutter 的开发体验;
  • 与 Android 深度集成:可以无缝使用 Android 系统特性;
  • 性能优化:智能重组,只更新变化的部分;
  • 类型安全:Kotlin 的类型系统提供更好的安全性。

Android 传统 View 的优势

  • 成熟稳定:经过多年验证,生态完善;
  • 系统集成:与Android系统紧密集成;
  • 性能可控:可以精确控制绘制过程;
  • 丰富的系统API:可以直接使用所有 Android API。

iOS 的优势

  • 系统原生:与 iOS 系统完美集成;
  • 性能极致:充分利用硬件特性;
  • 设计一致性:符合 Apple 设计规范;
  • Core Animation:强大的动画和图形处理能力。

选择建议

场景 推荐方案 理由
跨平台应用 Flutter 一套代码,维护成本低
Android 专属复杂 UI Compose 现代化,与系统深度集成
需要系统底层 API 原生View 可以使用所有平台特性
快速原型开发 Flutter 热重载,开发效率高
性能要求极致 原生 View 可以做到极致优化
团队技术栈 根据团队熟悉度选择 学习成本和开发效率平衡

2.3 Flutter 渲染引擎演进

Flutter 的渲染引擎经历了重要演进:

  1. Skia 引擎时代(Flutter 1.0 - 3.x)
  • 基于 Google 的 Skia 2D图形库;
  • 运行时编译着色器,可能导致首次渲染卡顿;
  • 成熟稳定,但在某些场景下性能有限制。
  1. Impeller 引擎时代(Flutter 3.10+)
  • iOS平台:Flutter 3.10+ 默认启用 Impeller;
  • Android平台:Flutter 3.16+ 可选启用,预计未来版本默认启用;
  • 核心优势
    • 预编译着色器,消除 shader compilation jank
    • 更好的 GPU 利用率和渲染性能;
    • 专为 Flutter 优化设计;
    • 支持更复杂的视觉效果。
  1. 使用建议
# flutter/android/app/src/main/AndroidManifest.xml
# 在Android上启用Impeller(实验性)
<meta-data
    android:name="io.flutter.embedding.android.EnableImpeller"
    android:value="true" />

对于自定义绘制而言,Impeller 带来的主要改进:

  • 更流畅的动画:预编译着色器消除卡顿;
  • 更好的复杂图形性能:优化的 GPU 渲染管线;
  • 一致的跨平台体验:统一的渲染行为。

3. 自定义ChartBar 组件实战

想象一下,你是一名数据分析师,老板给你一堆销售数据,要求你做一个漂亮的图表展示。
下面跟随这个场景,一步步实现一个功能完整的 ChartBar 组件。

需求1:老板说"先给我画个坐标轴出来看看"

场景: 老板拿着一张白纸说:"你先给我画个坐标轴,X 轴放月份,Y 轴放销售额。"

好的,先搭建基础框架:

class ChartBar extends StatefulWidget {
  final double? width;
  final double? height;
  final List<String> xData;
  final List<double> yData;

  const ChartBar({
    super.key,
    this.width,
    this.height,
    required this.xData,
    required this.yData,
  });

  @override
  State<ChartBar> createState() => _ChartBarState();
}

class _ChartBarState extends State<ChartBar> {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: widget.width ?? 300,
      height: widget.height ?? 200,
      child: CustomPaint(
        painter: ChartBarPainter(xData: widget.xData, yData: widget.yData),
      ),
    );
  }
}

然后创建我们的画师(CustomPainter):

class ChartBarPainter extends CustomPainter {
  final List<String> xData;
  final List<double> yData;

  ChartBarPainter({required this.xData, required this.yData});

  late double chartWidth;
  late double chartHeight;

  @override
  void paint(Canvas canvas, Size size) {
    _setupCoordinateSystem(canvas, size);
    // 老板的第一个需求:画坐标轴
    _drawAxis(canvas, size);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

关键技巧:坐标系变换

Flutter 的坐标系原点在左上角,但我们的图表需要数学坐标系(左下角为原点):

  /// 初始化坐标系
  void _setupCoordinateSystem(Canvas canvas, Size size) {
    // 🎯 关键操作:将坐标系原点移到左下角
    canvas.translate(0, size.height);

    // 预留边距,给文字标签留空间
    final double margin = 30.0;
    canvas.translate(margin, -margin);

    // 计算实际绘制区域
    chartWidth = size.width - 2 * margin;
    chartHeight = size.height - 2 * margin;
  }

  /// 绘制坐标轴
  void _drawAxis(Canvas canvas, Size size) {
    final Paint axisPaint = Paint()
      ..color = Colors.grey[600]!
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;

    final Path axisPath = Path();

    // X轴:从左到右的水平线
    axisPath.moveTo(0, 0);
    axisPath.lineTo(chartWidth, 0);

    // Y轴:从下到上的垂直线
    axisPath.moveTo(0, 0);
    axisPath.lineTo(0, -chartHeight);

    canvas.drawPath(axisPath, axisPaint);
  }

运行效果: 一个简洁的 L 形坐标轴出现了:

坐标轴

需求2:老板说"加点网格线,看起来专业点"

场景: 老板看了坐标轴后说:"嗯,不错,但是加点网格线吧,这样看数据更清楚。"

@override
void paint(Canvas canvas, Size size) {
  _setupCoordinateSystem(canvas, size);
  _drawAxis(canvas, size);
  _drawGrid(canvas, size); // 新增:绘制网格线
}

void _drawGrid(Canvas canvas, Size size) {
  final Paint gridPaint = Paint()
    ..color = Colors.grey[300]!.withOpacity(0.5)
    ..strokeWidth = 0.5;

  // Y轴网格线(水平线)
  final int ySteps = 5;
  for (int i = 1; i <= ySteps; i++) {
    final double y = (chartHeight / ySteps) * i;
    canvas.drawLine(
      Offset(0, y),
      Offset(chartWidth, y),
      gridPaint,
    );
  }
  
  // X轴网格线(垂直线)
  final double xStep = chartWidth / xData.length;
  for (int i = 1; i < xData.length; i++) {
    final double x = xStep * i;
    canvas.drawLine(Offset(x, 0), Offset(x, -chartHeight), gridPaint);
  }
}

运行效果: 图表有了专业的网格背景!

网格背景

需求3:老板说"把数据用柱子表示出来"

场景: 老板兴奋地说:"现在把我们的销售数据画成柱状图,我要看到每个月的业绩!"

@override
void paint(Canvas canvas, Size size) {
  _setupCoordinateSystem(canvas, size);
  _drawAxis(canvas, size);
  _drawGrid(canvas, size);
  _drawBars(canvas, size); // 新增:绘制柱状图
}

void _drawBars(Canvas canvas, Size size) {
  if (yData.isEmpty) return;

  final Paint barPaint = Paint()..style = PaintingStyle.fill;

  // 找到最大值,用于计算比例
  final double maxValue = yData.reduce((a, b) => a > b ? a : b);
  final double barWidth = chartWidth / yData.length * 0.6; // 柱子占60%宽度
  final double barSpacing = chartWidth / yData.length;

  for (int i = 0; i < yData.length; i++) {
    // 计算柱子高度(按比例)
    final double barHeight = (yData[i] / maxValue) * chartHeight;
    final double x = barSpacing * i + (barSpacing - barWidth) / 2;

    // 🎨 根据数值大小设置不同颜色
    barPaint.color = _getBarColor(yData[i], maxValue);

    // 绘制顶部圆角矩形柱子
    final RRect barRect = RRect.fromRectAndCorners(
      Rect.fromLTWH(x, 0, barWidth, -barHeight),
      topLeft: Radius.circular(4.0),
      topRight: Radius.circular(4.0),
    );

    canvas.drawRRect(barRect, barPaint);
  }
}

Color _getBarColor(double value, double maxValue) {
  final double ratio = value / maxValue;
  if (ratio > 0.8) return Colors.red[400]!;    // 高业绩:红色
  if (ratio > 0.5) return Colors.orange[400]!; // 中等业绩:橙色
  return Colors.green[400]!;                   // 低业绩:绿色
}

运行效果: 彩色的柱状图出现了,不同高度的柱子代表不同的销售额!

柱状图

需求4:老板说"我看不懂这些柱子代表什么,加上标签"

场景: 老板皱着眉头说:"这些柱子很漂亮,但我不知道哪个是1月,哪个是2月,还有具体数值是多少。"

@override
void paint(Canvas canvas, Size size) {
  _setupCoordinateSystem(canvas, size);
  _drawAxis(canvas, size);
  _drawGrid(canvas, size);
  _drawBars(canvas, size);
  _drawLabels(canvas, size); // 新增:绘制标签
}

void _drawLabels(Canvas canvas, Size size) {
  final TextPainter textPainter = TextPainter(
    textDirection: TextDirection.ltr,
  );

  // X轴标签(月份,这里按 yData 下标来算)
  final double barSpacing = chartWidth / yData.length;
  for (int i = 0; i < yData.length; i++) {
    textPainter.text = TextSpan(
      text: "${i + 1}",
      style: const TextStyle(
        color: Colors.black87,
        fontSize: 12,
        fontWeight: FontWeight.w500,
      ),
    );
    textPainter.layout();

    // 居中对齐
    final double x = barSpacing * i + barSpacing / 2 - textPainter.width / 2;
    textPainter.paint(canvas, Offset(x, 10));
  }

  // Y轴标签(销售额)
  final double maxValue = yData.reduce((a, b) => a > b ? a : b);
  final int ySteps = 5;
  for (int i = 0; i <= ySteps; i++) {
    final double value = (maxValue / ySteps) * i;
    textPainter.text = TextSpan(
      text: '${value.toStringAsFixed(0)}万',
      style: const TextStyle(color: Colors.black54, fontSize: 10),
    );
    textPainter.layout();

    final double y = -(chartHeight / ySteps) * i - textPainter.height / 2;
    textPainter.paint(canvas, Offset(-textPainter.width - 8, y));
  }
}

运行效果: 图表有了清晰的月份标签和销售额刻度!

柱状图

需求5:老板说"能不能加点动画效果,显得高大上一些"

场景: 老板看着静态图表说:"现在看起来不错,但能不能让柱子从下往上长出来?这样演示给客户看会很酷!"

首先修改 State 类,添加动画控制器:

class _ChartBarState extends State<ChartBar>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      duration: const Duration(milliseconds: 1500), // 1.5秒动画
      vsync: this,
    );
    _animation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(
        parent: _animationController,
        curve: Curves.easeOutBack, // 回弹效果
      ),
    );

    // 启动动画
    _animationController.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Container(
          width: widget.width ?? 300,
          height: widget.height ?? 200,
          child: CustomPaint(
            painter: ChartBarPainter(
              xData: widget.xData,
              yData: widget.yData,
              animationValue: _animation.value, // 传递动画值
            ),
          ),
        );
      },
    );
  }
}

然后在 ChartBarPainter 中使用动画值:

class ChartBarPainter extends CustomPainter {
  final double animationValue;
  
  ChartBarPainter({
    required this.xData,
    required this.yData,
    this.animationValue = 1.0,
  });

  void _drawBars(Canvas canvas, Size size) {
    // ... 其他代码保持不变
    
    for (int i = 0; i < yData.length; i++) {
      // 🎬 关键:柱子高度乘以动画值
      final double barHeight = (yData[i] / maxValue) * chartHeight * animationValue;
      
      // ... 绘制逻辑保持不变
    }
  }
}

运行效果: 柱子从底部优雅地长出来,带有回弹效果!

动画效果

需求6:老板说"我想点击柱子看详细信息"

场景: 老板用手指点着屏幕说:"能不能让我点击某个柱子,然后高亮显示,这样我就能重点关注某个月的数据。"

class _ChartBarState extends State<ChartBar>
    with SingleTickerProviderStateMixin {

  // ... 其他代码保持不变

  int? _touchedIndex; // 被点击的柱子索引

  void _handleTouch(Offset localPosition) {
    // 计算点击位置对应的柱子
    final double adjustedX = localPosition.dx - 30; // 减去边距
    final double barSpacing = (widget.width ?? 300 - 60) / widget.yData.length;
    final int index = (adjustedX / barSpacing).floor();

    if (index >= 0 && index < widget.yData.length) {
      setState(() {
        _touchedIndex = index;
      });

      // 🎉 可以在这里添加回调,通知外部组件
      widget.onBarTap?.call(index);
      //print('点击了${widget.yData[index]},销售额:${widget.yData[index]}万');
    }
  }


  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (details) {
        final RenderBox box = context.findRenderObject() as RenderBox;
        final Offset localPosition = box.globalToLocal(details.globalPosition);
        _handleTouch(localPosition);
      },
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Container(
            width: widget.width ?? 300,
            height: widget.height ?? 200,
            child: CustomPaint(
              painter: ChartBarPainter(
                xData: widget.xData,
                yData: widget.yData,
                animationValue: _animation.value, // 传递动画值
                touchedIndex: _touchedIndex,
              ),
            ),
          );
        },
      ),
    );
  }
}

在 ChartBarPainter 中处理高亮效果:

class ChartBarPainter extends CustomPainter {
  final int? touchedIndex;
  
  ChartBarPainter({
    required this.xData,
    required this.yData,
    this.animationValue = 1.0,
    this.touchedIndex,
  });

  void _drawBars(Canvas canvas, Size size) {

    // ... 其他代码保持不变

    for (int i = 0; i < yData.length; i++) {
      // 计算柱子高度(按比例)
      final double barHeight =
          (yData[i] / maxValue) * chartHeight * animationValue;
      final double x = barSpacing * i + (barSpacing - barWidth) / 2;

      RRect barRect;
      // 🎨 被点击的柱子特殊处理
      if (touchedIndex == i) {
        barPaint.color = Colors.blue[600]!; // 高亮颜色
        // 可以让被点击的柱子稍微宽一点
        barRect = RRect.fromRectAndCorners(
          Rect.fromLTWH(x - 2, 0, barWidth + 4, -barHeight),
          topLeft: Radius.circular(4.0),
          topRight: Radius.circular(4.0),
        );
      } else {
        // 🎨 根据数值大小设置不同颜色
        barPaint.color = _getBarColor(yData[i], maxValue);
        // 绘制顶部圆角矩形柱子
        barRect = RRect.fromRectAndCorners(
          Rect.fromLTWH(x, 0, barWidth, -barHeight),
          topLeft: Radius.circular(4.0),
          topRight: Radius.circular(4.0),
        );
      }
      canvas.drawRRect(barRect, barPaint);
    }
  }

运行效果: 点击柱子会高亮显示,用户体验有所提升!

点击效果

需求7:老板说"我想要柱子随手指滑动更新"

场景: 老板看着柱状图说:"柱状图很好,但我还想随手指滑动显示详细点信息,另外手指抬起时就恢复之前的样子"

class _ChartBarState extends State<ChartBar>
    with SingleTickerProviderStateMixin {

  // ... 其他代码保持不变

  /// 更新触摸的index
  void _updateTouchedIndex(BuildContext context, Offset position) {
    RenderBox box = context.findRenderObject() as RenderBox;
    Offset localPosition = box.globalToLocal(position);
    _handleTouch(localPosition);
  }

  /// 手指离开时: 点击、触摸结束
  void _onBarTapFinish() {
    setState(() {
      _touchedIndex = null;
    });
    widget.onBarTap?.call(-1);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (details) =>
          _updateTouchedIndex(context, details.globalPosition),
      onTapUp: (details) => _onBarTapFinish(),
      onTapCancel: () => _onBarTapFinish(),
      onHorizontalDragUpdate: (details) =>
          _updateTouchedIndex(context, details.globalPosition),
      onHorizontalDragEnd: (details) => _onBarTapFinish(),
      onHorizontalDragCancel: () => _onBarTapFinish(),
      child: AnimatedBuilder(...),
    );
  }
}

运行效果: 柱子会随手指滑动高亮显示,用户体验大大提升!

柱子随手指滑动更新

需求8:老板说"我还想看趋势,加条折线图"

场景: 老板看着柱状图说:"柱状图很好,但我还想看销售趋势,能不能在上面加一条线?"

@override
void paint(Canvas canvas, Size size) {
  _setupCoordinateSystem(canvas, size);
  _drawAxis(canvas, size);
  _drawGrid(canvas, size);
  _drawBars(canvas, size);
  _drawTrendLine(canvas, size); // 新增:绘制趋势线
  _drawLabels(canvas, size);
}

void _drawTrendLine(Canvas canvas, Size size) {
  if (yData.length < 2) return;
  
  final Paint linePaint = Paint()
    ..color = Colors.deepPurple
    ..strokeWidth = 3.0
    ..style = PaintingStyle.stroke;
  
  final Paint pointPaint = Paint()
    ..color = Colors.deepPurple
    ..style = PaintingStyle.fill;
  
  final Path linePath = Path();
  final double maxValue = yData.reduce((a, b) => a > b ? a : b);
  final double barSpacing = chartWidth / yData.length;
  
  // 计算第一个点的位置
  final double firstX = barSpacing / 2;
  final double firstY = (yData[0] / maxValue) * chartHeight * animationValue;
  linePath.moveTo(firstX, -firstY);
  
  // 连接其他点
  for (int i = 1; i < yData.length; i++) {
    final double x = barSpacing * i + barSpacing / 2;
    final double y = (yData[i] / maxValue) * chartHeight * animationValue;
    linePath.lineTo(x, -y);
  }
  
  // 绘制线条
  canvas.drawPath(linePath, linePaint);
  
  // 绘制数据点
  for (int i = 0; i < yData.length; i++) {
    final double x = barSpacing * i + barSpacing / 2;
    final double y = (yData[i] / maxValue) * chartHeight * animationValue;
    canvas.drawCircle(Offset(x, -y), 4.0, pointPaint);
    
    // 白色内圈
    canvas.drawCircle(Offset(x, -y), 2.0, Paint()..color = Colors.white);
  }
}

运行效果: 图表既有柱状图显示具体数值,又有折线图显示趋势!

折线图

需求9:老板说"能不能让线条更平滑一些"

场景: 老板说:"折线图不错,但能不能让线条更平滑,像那些高端的商业图表一样?"

void _drawSmoothTrendLine(Canvas canvas, Size size) {
  if (yData.length < 2) return;
  
  final Paint linePaint = Paint()
    ..color = Colors.deepPurple
    ..strokeWidth = 3.0
    ..style = PaintingStyle.stroke;
  
  // 收集所有数据点
  final List<Offset> points = [];
  final double maxValue = yData.reduce((a, b) => a > b ? a : b);
  final double barSpacing = chartWidth / yData.length;
  
  for (int i = 0; i < yData.length; i++) {
    final double x = barSpacing * i + barSpacing / 2;
    final double y = (yData[i] / maxValue) * chartHeight * animationValue;
    points.add(Offset(x, -y));
  }
  
  // 使用贝塞尔曲线创建平滑路径
  final Path smoothPath = Path();
  _createSmoothPath(smoothPath, points);
  
  canvas.drawPath(smoothPath, linePaint);
  
  // 绘制数据点(保持不变)
  for (final point in points) {
    canvas.drawCircle(point, 4.0, Paint()..color = Colors.deepPurple);
    canvas.drawCircle(point, 2.0, Paint()..color = Colors.white);
  }
}

void _createSmoothPath(Path path, List<Offset> points) {
  if (points.length < 2) return;
  
  path.moveTo(points[0].dx, points[0].dy);
  
  // 使用二次贝塞尔曲线连接点
  for (int i = 1; i < points.length; i++) {
    final Offset current = points[i];
    final Offset previous = points[i - 1];
    
    // 控制点在两点中间
    final double controlPointX = previous.dx + (current.dx - previous.dx) / 2;
    
    path.quadraticBezierTo(
      controlPointX, previous.dy, // 控制点
      current.dx, current.dy,     // 终点
    );
  }
}

运行效果: 优雅的曲线让图表看起来更加专业!

折线图2

需求10:老板说"我想看到数据的范围区间"

场景: 老板拿着更复杂的数据说:"有时候我们的数据不是单一值,而是一个范围,比如销售额在20-30万之间,能支持这种显示吗?"

// 首先定义范围数据结构
class RangeData {
  final double min;
  final double max;
  
  const RangeData({required this.min, required this.max});
}

// 修改ChartBar支持范围数据
class ChartBar extends StatefulWidget {
  final double? width;
  final double? height;
  final List<String> xData;
  final List<double> yData; // 单一数据
  final List<RangeData> yRangeData; // 范围数据
  final bool isRangeMode; // 是否为范围模式
  final Function(int index)? onBarTap; // 添加bar点击事件回调

  const ChartBar({
    super.key,
    this.width,
    this.height,
    required this.xData,
    this.yData = const [],
    this.yRangeData = const [],
    this.isRangeMode = false,
    this.onBarTap,
  });

  @override
  State<ChartBar> createState() => _ChartBarState();
}

  // 在ChartPainter中绘制范围柱状图
  void _drawRangeBars(Canvas canvas, Size size) {
    if (yRangeData.isEmpty) return;

    final Paint barPaint = Paint()..style = PaintingStyle.fill;

    // 计算最大值(包括所有范围的最大值)
    final double maxValue = yRangeData
        .map((data) => data.max)
        .reduce((a, b) => a > b ? a : b);

    final double barWidth = chartWidth / yRangeData.length * 0.6;
    final double barSpacing = chartWidth / yRangeData.length;

    for (int i = 0; i < yRangeData.length; i++) {
      final RangeData data = yRangeData[i];
      final double minHeight =
          -(data.min / maxValue) * chartHeight * animationValue;
      final double maxHeight =
          -(data.max / maxValue) * chartHeight * animationValue;
      final double x = barSpacing * i + (barSpacing - barWidth) / 2;

      // 绘制范围柱(从min到max)
      barPaint.color = touchedIndex == i
          ? Colors.blue[600]!
          : _getBarColor(data.max, maxValue);

      final RRect rangeRect = RRect.fromRectAndRadius(
        Rect.fromLTWH(x, minHeight, barWidth, maxHeight - minHeight),
        const Radius.circular(4.0),
      );

      canvas.drawRRect(rangeRect, barPaint);
  }

运行效果: 图表可以显示数据范围,每个柱子代表一个区间!

范围柱子

需求11:老板说"最后加个警戒线,超过目标就显示红色"

场景: 老板最后说:"我们有销售目标,比如40万,超过这个线就用红色警告,这样一眼就能看出哪个月超标了。"

class ChartBar extends StatefulWidget {
  // ... 其他参数
  final double? warningLine;     // 警戒线数值
  final Color warningColor;      // 警戒线颜色
  
  const ChartBar({
    super.key,
    // ... 其他参数
    this.warningLine,
    this.warningColor = Colors.red,
  });
}

// 在ChartPainter中绘制警戒线
  void _drawWarningLine(Canvas canvas, Size size) {
    if (warningLine == null) return;

    final double maxValue = yData.reduce((a, b) => a > b ? a : b) ?? 0;
    if (warningLine! > maxValue) return; // 警戒线超出范围就不显示

    final double warningY = (warningLine! / maxValue) * chartHeight;

    // 绘制虚线警戒线
    final Paint warningPaint = Paint()
      ..color = warningColor
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;

    _drawDashedLine(
      canvas,
      Offset(0, -warningY),
      Offset(chartWidth, -warningY),
      warningPaint,
    );

    // 绘制警戒线标签
    final TextPainter textPainter = TextPainter(
      text: TextSpan(
        text: '目标线: ${warningLine!.toStringAsFixed(0)}万',
        style: TextStyle(
          color: warningColor,
          fontSize: 10,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
      canvas,
      Offset(chartWidth - textPainter.width, -warningY - 30),
    );
  }

  void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
    const double dashWidth = 5.0;
    const double dashSpace = 3.0;
    double distance = (end - start).distance;
    double dashCount = (distance / (dashWidth + dashSpace)).floor().toDouble();

    for (int i = 0; i < dashCount; i++) {
      double startX =
          start.dx +
          (end.dx - start.dx) * (i * (dashWidth + dashSpace)) / distance;
      double endX =
          start.dx +
          (end.dx - start.dx) *
              (i * (dashWidth + dashSpace) + dashWidth) /
              distance;
      double startY =
          start.dy +
          (end.dy - start.dy) * (i * (dashWidth + dashSpace)) / distance;
      double endY =
          start.dy +
          (end.dy - start.dy) *
              (i * (dashWidth + dashSpace) + dashWidth) /
              distance;

      canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
    }
  }

// 修改柱子颜色逻辑,超过警戒线的显示警告色
Color _getBarColor(double value, double maxValue) {
  if (warningLine != null && value > warningLine!) {
    return warningColor; // 超过警戒线用警告色
  }
  
  final double ratio = value / maxValue;
  if (ratio > 0.8) return Colors.red[400]!;
  if (ratio > 0.5) return Colors.orange[400]!;
  return Colors.green[400]!;
}

最终效果: 一个功能完整的图表组件诞生了!它有坐标轴、网格、柱状图、折线图、动画、交互、范围显示和警戒线。

警戒线

完整的 paint 方法

@override
void paint(Canvas canvas, Size size) {
  _setupCoordinateSystem(canvas, size);
  _drawAxis(canvas, size);
  _drawGrid(canvas, size);
  
  if (isRangeMode) {
    _drawRangeBars(canvas, size);
  } else {
    _drawBars(canvas, size);
  }
  
  _drawTrendLine(canvas, size);
  _drawWarningLine(canvas, size);
  _drawLabels(canvas, size);
}

通过这种需求驱动的方式,我们一步步构建了一个功能丰富的图表组件。
实际的业务中可以根据实际问题进行扩展,比如扩展成下面的具有分段式效果:


分段式图

4. 注意事项

性能优化 & 内存管理

  • 重绘优化
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) {
  return oldDelegate.yData != yData ||
         oldDelegate.xData != xData ||
         oldDelegate.touchedIndex != touchedIndex ||
         oldDelegate.animationValue != animationValue;
}
  • 缓存优化
class ChartPainter extends CustomPainter {
  Path? _cachedAxisPath;
  Path? _cachedGridPath;
  
  Path get axisPath {
    if (_cachedAxisPath == null) {
      _cachedAxisPath = Path();
      _buildAxisPath(_cachedAxisPath!);
    }
    return _cachedAxisPath!;
  }
  
  void _buildAxisPath(Path path) {
    // 构建坐标轴路径
  }
}
  • 分层绘制
class LayeredChartPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 使用 saveLayer 进行分层绘制
    canvas.saveLayer(Offset.zero & size, Paint());
    
    _drawBackground(canvas, size);
    _drawGrid(canvas, size);
    _drawBars(canvas, size);
    _drawOverlay(canvas, size);
    
    canvas.restore();
  }
}
  • 内存管理
class ChartPainter extends CustomPainter {
  // 使用对象池避免频繁创建对象
  static final Paint _paintPool = Paint();
  static final Path _pathPool = Path();
  
  @override
  void paint(Canvas canvas, Size size) {
    // 重用Paint对象
    _paintPool
      ..color = Colors.blue
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    
    // 重用Path对象
    _pathPool.reset();
    _pathPool.moveTo(0, 0);
    // ...
  }
}

5. 小结

通过本文的介绍,从零开始实现了一个功能相对完整的 ChartBar 组件。

  • 核心要点
  1. 坐标系统变换:理解 Flutter 坐标系统,掌握 canvas 变换方法;
  2. 绘制基础:掌握 Paint、Path、Canvas 的使用;
  3. 文本绘制:学会使用 TextPainter 绘制文本;
  4. 动画集成:将动画与自定义绘制结合;
  5. 交互处理:实现触摸交互和手势识别。
  • 最佳实践
  1. 性能优化:合理使用 shouldRepaint,避免不必要的重绘;
  2. 代码组织:将复杂的绘制逻辑拆分成小方法;
  3. 参数设计:提供丰富的配置选项,提高组件复用性;
  4. 错误处理:对异常数据进行处理,提高组件健壮性。
  • 进阶方向
  1. 3D效果:使用 Matrix4 实现 3D 变换效果;
  2. 复杂动画:结合多个 AnimationController 实现复杂动画;
  3. 数据驱动:支持实时数据更新和流式数据;
  4. 主题适配:支持 Material DesignCupertino 主题等。

Flutter 自定义 View 和 Android 中的 canvas 很相似,掌握后能够实现非常灵活和高性能的UI效果。


参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容