Flutter 使用 AI Cursor 快速完成一个图表封装【提效】

Flutter 使用 Cursor 快速完成图表封装(TWBarChartView)

前言

在现代移动应用开发中,数据可视化是不可或缺的功能。虽然市面上有很多成熟的图表库,但有时我们需要高度定制化的图表组件来满足特定的设计需求。本文记录了我使用 Cursor AI 编程助手快速完成一个 Flutter 自定义柱状图组件(TWBarChartView)的完整过程,包括遇到的问题和解决方案。

项目背景

在项目中,我们需要一个支持以下特性的柱状图组件:

  • 支持平均值水平线显示
  • 支持高亮特定柱子
  • 支持两种显示模式:平均值模式和滚动模式
  • 高度可定制的样式
  • 当平均值超出数据范围时能自动调整视图

原型

效果 demo

代码

dart 复制代码
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:dotted_line/dotted_line.dart';

enum TWBarChartType {
  average,
  scroll,
}

class TWBarChartView extends StatelessWidget {
  final double width;
  final double height;
  final List<double> data;
  final double averageValue;
  final int? highlightedIndex;
  final Color barColor;
  final Color highlightedBarColor;
  final Color averageLineColor;
  final Decoration? decoration;
  final String averageLabel;
  final EdgeInsets padding;
  final Widget? emptyWidget;
  final TWBarChartType type;

  /// 柱子宽度, 仅在type为 TWBarChartType.average 时有效
  final double barWidth;

  const TWBarChartView({
    super.key,
    required this.width,
    required this.height,
    required this.data,
    required this.averageValue,
    this.highlightedIndex,
    this.barColor = Colors.grey,
    this.highlightedBarColor = Colors.orange,
    this.averageLineColor = Colors.orange,
    this.decoration,
    this.averageLabel = '周边均价',
    this.padding = EdgeInsets.zero,
    this.emptyWidget,
    this.type = TWBarChartType.average,
    this.barWidth = 30,
  });

  double get containHeight => height - padding.vertical;

  @override
  Widget build(BuildContext context) {
    if (data.isEmpty) {
      return emptyWidget ??
          Container(
            width: width,
            height: height,
            alignment: Alignment.center,
            decoration: decoration ??
                BoxDecoration(
                  color: Colors.grey.shade200,
                  borderRadius: BorderRadius.circular(8),
                ),
            child: const Text('暂无数据'),
          );
    }

    // 将 averageValue 也纳入最大值和最小值的计算,确保平均值线能在视图范围内显示
    final dataMaxValue = data.reduce((a, b) => a > b ? a : b);
    final dataMinValue = data.reduce((a, b) => a < b ? a : b);
    final maxValue = max(dataMaxValue, averageValue);
    final minValue = min(dataMinValue, averageValue);
    final valueRange = maxValue - minValue;

    Widget resultWidget = Stack(
      alignment: Alignment.bottomLeft,
      children: [
        // 柱状图
        _buildChart(
          maxValue: maxValue,
          minValue: minValue,
          valueRange: valueRange,
        ),

        // 平均值水平线
        Positioned(
          bottom: _calculateAverageLinePosition(
            availableHeight: containHeight,
            value: averageValue,
            minValue: minValue,
            valueRange: valueRange,
          ),
          left: 0,
          right: 0,
          child: DottedLine(
            lineThickness: 1,
            dashColor: highlightedBarColor,
          ),
        ),
        // 平均值标签
        Positioned(
          bottom: _calculateAverageLinePosition(
            availableHeight: containHeight,
            value: averageValue,
            minValue: minValue,
            valueRange: valueRange,
          ),
          right: 10,
          child: Text(
            averageLabel,
            style: TextStyle(
              color: highlightedBarColor,
              fontSize: 12,
              fontWeight: FontWeight.w400,
            ),
          ),
        ),
      ],
    );

    resultWidget = Container(
      width: width,
      height: height,
      decoration: decoration ??
          BoxDecoration(
            color: Colors.grey.shade200,
            borderRadius: BorderRadius.circular(8),
          ),
      child: resultWidget,
    );

    return resultWidget;
  }

  /// 构建柱状图
  Widget _buildChart({
    required double maxValue,
    required double minValue,
    required double valueRange,
  }) {
    Widget resultWidget = switch (type) {
      TWBarChartType.average => _buildAverageChart(
          maxValue: maxValue,
          minValue: minValue,
          valueRange: valueRange,
        ),
      TWBarChartType.scroll => _buildScrollChart(
          maxValue: maxValue,
          minValue: minValue,
          valueRange: valueRange,
        ),
    };
    resultWidget = Padding(
      padding: padding,
      child: resultWidget,
    );

    return resultWidget;
  }

  /// 构建滚动柱状图
  Widget _buildScrollChart({
    required double maxValue,
    required double minValue,
    required double valueRange,
  }) {
    return ListView.separated(
      scrollDirection: Axis.horizontal,
      itemBuilder: (context, index) {
        return SizedBox(
          height: containHeight,
          child: Align(
            alignment: Alignment.bottomCenter,
            child: _buildBar(
              availableHeight: containHeight,
              value: data[index],
              index: index,
              maxValue: maxValue,
              minValue: minValue,
              valueRange: valueRange,
              type: type,
            ),
          ),
        );
      },
      separatorBuilder: (context, index) => const SizedBox(width: 10),
      itemCount: data.length,
    );
  }

  /// 构建平均值柱状图
  Widget _buildAverageChart({
    required double maxValue,
    required double minValue,
    required double valueRange,
  }) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      crossAxisAlignment: CrossAxisAlignment.end,
      children: List.generate(
        data.length,
        (index) => _buildBar(
          availableHeight: containHeight,
          value: data[index],
          index: index,
          maxValue: maxValue,
          minValue: minValue,
          valueRange: valueRange,
          type: type,
        ),
      ),
    );
  }

  /// 构建柱状图
  /// [value] 值
  /// [index] 索引
  /// [maxValue] 最大值
  /// [minValue] 最小值
  /// [valueRange] 值范围
  /// [availableHeight] 可用高度
  Widget _buildBar({
    required double availableHeight,
    required double value,
    required int index,
    required double maxValue,
    required double minValue,
    required double valueRange,
    required TWBarChartType type,
  }) {
    final isHighlighted = highlightedIndex == index;

    // 修复最小值显示问题:确保最小柱子也有最小高度,但允许值为0时显示为0
    double barHeight = _calculateAverageLinePosition(
      availableHeight: availableHeight,
      value: value,
      minValue: minValue,
      valueRange: valueRange,
    );

    return Container(
      width: type == TWBarChartType.average
          ? (width - padding.horizontal) / data.length * 0.6
          : barWidth, // 柱子宽度
      height: barHeight,
      decoration: BoxDecoration(
        color: isHighlighted ? highlightedBarColor : barColor,
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      ),
    );
  }

  /// 计算平均值水平线位置
  /// [availableHeight] 可用高度
  /// [value] 值
  /// [minValue] 最小值
  /// [valueRange] 值范围
  double _calculateAverageLinePosition({
    required double availableHeight,
    required double value,
    required double minValue,
    required double valueRange,
  }) {
    // 修复最小值显示问题:确保最小柱子也有最小高度,但允许值为0时显示为0
    double barHeight = 0.0;
    if (valueRange > 0.0) {
      final normalizedValue = (value - minValue) / valueRange;
      barHeight = normalizedValue * availableHeight * 0.8;
      // 只有当值不为0且高度小于1像素时,才设置最小高度
      if (value >= 0 && barHeight < 1) {
        barHeight = 1;
      }
    }
    return barHeight;
  }
}

开发过程记录

第一阶段:基础组件结构

最初的 TWBarChartView 组件已经具备了基本的柱状图功能,但在使用过程中发现了一些问题。

dart 复制代码
enum TWBarChartType {
  average,  // 平均值模式:所有柱子在一行显示
  scroll,   // 滚动模式:可横向滚动查看
}

class TWBarChartView extends StatelessWidget {
  final double width;
  final double height;
  final List<double> data;
  final double averageValue;
  final TWBarChartType type;
  // ... 其他属性
}

第二阶段:解决滚动模式布局问题

问题发现

在使用 TWBarChartType.scroll 类型时,发现了一个关键问题:

用户反馈: "为什么 _buildScrollChart 滚动的高度占满整个视图?"

问题分析

使用 Cursor 分析代码后,发现问题出现在 _buildScrollChart 方法中:

dart 复制代码
// 原始问题代码
Widget _buildScrollChart({...}) {
  return ListView.separated(
    scrollDirection: Axis.horizontal,
    itemBuilder: (context, index) {
      return _buildBar(...); // 直接返回柱子组件
    },
    // ...
  );
}

问题根源: ListView 在垂直方向上会强制子组件占满可用空间,导致所有柱子高度相同。

解决方案

通过 Cursor 的建议,我们采用了以下解决方案:

dart 复制代码
Widget _buildScrollChart({...}) {
  return ListView.separated(
    scrollDirection: Axis.horizontal,
    itemBuilder: (context, index) {
      return SizedBox(
        height: containHeight,
        child: Align(
          alignment: Alignment.bottomCenter,
          child: _buildBar(...),
        ),
      );
    },
    // ...
  );
}

关键改进:

  1. SizedBox 容器:提供明确的高度约束
  2. Align 组件:确保柱子对齐到底部,不会被强制拉伸
  3. 保持计算高度:柱子可以保持根据数据计算的真实高度
技术原理深入

Cursor 帮我分析了 Flutter 布局约束的传递机制:

scss 复制代码
Container(height: height)
  └── Stack
      └── ListView (scrollDirection: Axis.horizontal)
          └── SizedBox(height: containHeight)  // 固定高度容器
              └── Align(alignment: Alignment.bottomCenter)  // 不强制拉伸
                  └── _buildBar (height: barHeight)  // 保持计算高度

对比平均值模式的实现:

dart 复制代码
Row(
  crossAxisAlignment: CrossAxisAlignment.end,  // 底部对齐,不强制拉伸
  children: List.generate(data.length, (index) => _buildBar(...)),
)

第三阶段:优化平均值线显示范围

问题发现

用户反馈: "考虑如果 averageValue 值比最大值还大很多,比如 168,此时就无法展示平均值线在视图里"

在测试中发现,当平均值(158)远大于数据最大值(138)时,平均值线会超出视图范围。

原始计算逻辑
dart 复制代码
final maxValue = data.reduce((a, b) => a > b ? a : b);  // 只考虑数据
final minValue = data.reduce((a, b) => a < b ? a : b);  // 只考虑数据
优化解决方案

通过 Cursor 的协助,我们重新设计了计算逻辑:

dart 复制代码
// 将 averageValue 也纳入最大值和最小值的计算
final dataMaxValue = data.reduce((a, b) => a > b ? a : b);
final dataMinValue = data.reduce((a, b) => a < b ? a : b);
final maxValue = max(dataMaxValue, averageValue);
final minValue = min(dataMinValue, averageValue);
final valueRange = maxValue - minValue;

改进效果:

  • 视图范围从 33-138 扩展到 33-158
  • 平均值线现在显示在视图顶部可见位置
  • 所有柱子高度按新范围重新计算,保持正确比例

第四阶段:增强组件可定制性

添加柱子宽度配置

根据实际使用需求,增加了柱子宽度的可配置性:

dart 复制代码
class TWBarChartView extends StatelessWidget {
  /// 柱子宽度, 仅在type为 TWBarChartType.average 时有效
  final double barWidth;
  
  const TWBarChartView({
    // ...
    this.barWidth = 30,
  });
  
  // 在 _buildBar 中使用
  Container(
    width: type == TWBarChartType.average
        ? (width - padding.horizontal) / data.length * 0.6
        : barWidth, // 使用可配置的柱子宽度
    // ...
  )
}

Cursor AI 在开发过程中的作用

1. 问题诊断专家

当遇到布局问题时,Cursor 能够:

  • 快速定位问题根源(ListView 约束机制)
  • 分析 Flutter 布局原理
  • 提供详细的技术解释

2. 代码重构助手

  • 建议最佳实践的解决方案
  • 保持代码结构清晰
  • 确保修改不破坏现有功能

3. 技术文档生成

  • 自动生成详细的代码注释
  • 解释复杂的布局逻辑
  • 提供使用示例

最终实现效果

核心特性

  1. 双模式支持

    • TWBarChartType.average:所有柱子在一行显示,适合数据较少的场景
    • TWBarChartType.scroll:横向滚动查看,适合大量数据展示
  2. 智能视图范围

    • 自动将平均值纳入计算范围
    • 确保平均值线始终可见
    • 保持数据比例的准确性
  3. 高度可定制

    • 支持自定义颜色、尺寸、间距
    • 可配置柱子宽度
    • 支持高亮显示特定柱子

使用示例

dart 复制代码
TWBarChartView(
  width: 350.w,
  height: 200.w,
  data: const [120.0, 78.0, 72.0, 45.0, 138.0, 100.0, 35.0],
  averageValue: 158,  // 即使大于数据最大值也能正确显示
  type: TWBarChartType.scroll,
  highlightedIndex: 3,
  barColor: Colors.grey,
  barWidth: 25,
  padding: EdgeInsets.symmetric(horizontal: 10.w),
)

开发心得与总结

1. AI 辅助编程的优势

  • 快速问题定位:Cursor 能在几秒内分析出布局问题的根本原因
  • 最佳实践建议:提供符合 Flutter 设计理念的解决方案
  • 代码质量提升:自动优化代码结构,添加必要注释

2. Flutter 布局的关键认知

通过这次开发,深入理解了:

  • ListView 在不同滚动方向下的约束行为
  • Align 组件在布局中的重要作用
  • Container 高度约束的优先级机制

3. 组件设计的思考

  • 渐进式增强:从基础功能开始,逐步添加高级特性
  • 边界情况处理:考虑平均值超出数据范围等特殊场景
  • API 设计:保持接口简洁,同时提供足够的定制能力

性能与优化建议

  1. 数据处理优化
dart 复制代码
// 使用 dart:math 库的 min/max 函数替代 reduce
final maxValue = max(dataMaxValue, averageValue);
final minValue = min(dataMinValue, averageValue);
  1. 渲染优化
  • 在滚动模式下,ListView 自带视图复用机制
  • 避免在 build 方法中进行复杂计算
  1. 内存管理
  • 对于大量数据,考虑实现懒加载
  • 合理设置 ListView 的 cacheExtent

未来优化方向

  1. 动画支持:添加柱子高度变化动画
  2. 交互增强:支持点击、长按等手势操作
  3. 数据绑定:支持数据流的实时更新
  4. 主题适配:更好地集成 Flutter 主题系统

结语

1、 通过使用 Cursor AI 辅助开发,我们在短时间内完成了一个功能完整、设计精良的图表组件。这个过程不仅提高了开发效率,更重要的是通过 AI 的分析和建议,深入理解了 Flutter 布局机制的底层原理。

2、 AI 辅助编程不是替代程序员的思考,而是增强我们解决问题的能力。在复杂的技术问题面前,AI 可以提供快速的分析和多种解决方案,让我们能够专注于更高层次的架构设计和用户体验优化。

3、 这次的开发经历证明,合理利用 AI 工具可以显著提升开发质量和效率,是现代软件开发不可或缺的助力。

文章我也是通过 AI 生成,我在中间除了修改了下计算逻辑,大部分时间是在调试对接和自测。

相关推荐
暮紫李14 分钟前
项目中如何强制使用pnpm
前端
哈哈哈笑什么16 分钟前
如何防止恶意伪造前端唯一请求id
前端·后端
kevinzzzzzz19 分钟前
基于模块联邦打通多系统的探索
前端·javascript
小胖霞22 分钟前
彻底搞懂 JWT 登录认证与路由守卫(五)
前端·vue.js·node.js
用户938169125536023 分钟前
VUE3项目--组件递归调用自身
前端
昔人'32 分钟前
CSS content-visibility
前端·css
灵魂学者37 分钟前
Vue3.x —— ref 的使用
前端·javascript·vue.js
梦6501 小时前
VUE树形菜单组件如何实现展开/收起、全选/取消功能
前端·javascript·vue.js
我命由我123451 小时前
微信小程序 - 避免在 data 初始化中引用全局变量
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
可爱又迷人的反派角色“yang”1 小时前
Mysql数据库(二)
运维·服务器·前端·数据库·mysql·nginx·云计算