Flutter数据可视化:fl_chart图表库的高级应用
本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何使用fl_chart构建美观、交互式的财务数据可视化图表。
项目背景
BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。
引言
数据可视化是现代应用的重要特性,特别是对于财务管理类应用。用户需要直观地了解自己的收支状况、消费趋势和资产分布。优秀的数据可视化不仅能帮助用户更好地理解数据,还能提升应用的专业性和用户粘性。
fl_chart是Flutter生态中最受欢迎的图表库之一,它提供了丰富的图表类型、流畅的动画效果和灵活的自定义选项。在BeeCount项目中,我们使用fl_chart构建了完整的财务数据分析功能,包括趋势图、饼图、柱状图等多种图表类型。
fl_chart核心特性
丰富的图表类型
- 线性图(LineChart): 展示数据趋势变化
- 柱状图(BarChart): 对比不同类别数据
- 饼图(PieChart): 显示数据占比分布
- 散点图(ScatterChart): 展示数据相关性
- 雷达图(RadarChart): 多维度数据对比
强大的交互能力
- 触摸交互: 点击、长按、滑动等手势支持
- 动态更新: 数据变化时的流畅动画
- 自定义样式: 完全可定制的视觉效果
- 响应式设计: 适配不同屏幕尺寸
财务数据分析架构
数据模型设计
- // 统计数据基类
- abstract class ChartData {
- final DateTime date;
- final double value;
- final String label;
- const ChartData({
- required this.date,
- required this.value,
- required this.label,
- });
- }
- // 日收支统计
- class DailyStats extends ChartData {
- final double income;
- final double expense;
- final double net;
- const DailyStats({
- required DateTime date,
- required this.income,
- required this.expense,
- required this.net,
- }) : super(
- date: date,
- value: net,
- label: '',
- );
- factory DailyStats.fromTransaction(List<Transaction> transactions, DateTime date) {
- double income = 0;
- double expense = 0;
- for (final tx in transactions) {
- if (isSameDay(tx.happenedAt, date)) {
- switch (tx.type) {
- case 'income':
- income += tx.amount;
- break;
- case 'expense':
- expense += tx.amount;
- break;
- }
- }
- }
- return DailyStats(
- date: date,
- income: income,
- expense: expense,
- net: income - expense,
- );
- }
- }
- // 分类统计
- class CategoryStats extends ChartData {
- final String categoryName;
- final int transactionCount;
- final Color color;
- const CategoryStats({
- required DateTime date,
- required double value,
- required this.categoryName,
- required this.transactionCount,
- required this.color,
- }) : super(
- date: date,
- value: value,
- label: categoryName,
- );
- }
- // 月度趋势
- class MonthlyTrend extends ChartData {
- final int year;
- final int month;
- final double income;
- final double expense;
- const MonthlyTrend({
- required this.year,
- required this.month,
- required this.income,
- required this.expense,
- }) : super(
- date: DateTime(year, month),
- value: income - expense,
- label: '$year年$month月',
- );
- }
复制代码 数据处理服务
- class AnalyticsService {
- final BeeRepository repository;
- AnalyticsService(this.repository);
- // 获取指定时间范围的日统计数据
- Future<List<DailyStats>> getDailyStats({
- required int ledgerId,
- required DateTimeRange range,
- }) async {
- final transactions = await repository.getTransactionsInRange(
- ledgerId: ledgerId,
- range: range,
- );
- final Map<DateTime, List<Transaction>> groupedByDate = {};
- for (final tx in transactions) {
- final date = DateTime(tx.happenedAt.year, tx.happenedAt.month, tx.happenedAt.day);
- groupedByDate.putIfAbsent(date, () => []).add(tx);
- }
- final List<DailyStats> result = [];
- DateTime current = DateTime(range.start.year, range.start.month, range.start.day);
- final end = DateTime(range.end.year, range.end.month, range.end.day);
- while (!current.isAfter(end)) {
- final dayTransactions = groupedByDate[current] ?? [];
- result.add(DailyStats.fromTransaction(dayTransactions, current));
- current = current.add(const Duration(days: 1));
- }
- return result;
- }
- // 获取分类统计数据
- Future<List<CategoryStats>> getCategoryStats({
- required int ledgerId,
- required DateTimeRange range,
- required String type, // 'income' or 'expense'
- }) async {
- final transactions = await repository.getCategoryStatsInRange(
- ledgerId: ledgerId,
- range: range,
- type: type,
- );
- final Map<String, CategoryStatsData> categoryMap = {};
-
- for (final tx in transactions) {
- final categoryName = tx.categoryName ?? '未分类';
- final existing = categoryMap[categoryName];
-
- if (existing == null) {
- categoryMap[categoryName] = CategoryStatsData(
- categoryName: categoryName,
- totalAmount: tx.amount,
- transactionCount: 1,
- color: _getCategoryColor(categoryName),
- );
- } else {
- existing.totalAmount += tx.amount;
- existing.transactionCount += 1;
- }
- }
- return categoryMap.values
- .map((data) => CategoryStats(
- date: range.start,
- value: data.totalAmount,
- categoryName: data.categoryName,
- transactionCount: data.transactionCount,
- color: data.color,
- ))
- .toList()
- ..sort((a, b) => b.value.compareTo(a.value));
- }
- // 获取月度趋势数据
- Future<List<MonthlyTrend>> getMonthlyTrends({
- required int ledgerId,
- required int year,
- }) async {
- final List<MonthlyTrend> trends = [];
- for (int month = 1; month <= 12; month++) {
- final range = DateTimeRange(
- start: DateTime(year, month, 1),
- end: DateTime(year, month + 1, 1).subtract(const Duration(days: 1)),
- );
- final monthStats = await repository.getMonthStats(
- ledgerId: ledgerId,
- range: range,
- );
- trends.add(MonthlyTrend(
- year: year,
- month: month,
- income: monthStats.income,
- expense: monthStats.expense,
- ));
- }
- return trends;
- }
- Color _getCategoryColor(String categoryName) {
- // 为不同分类分配固定颜色
- final colors = [
- Colors.red.shade300,
- Colors.blue.shade300,
- Colors.green.shade300,
- Colors.orange.shade300,
- Colors.purple.shade300,
- Colors.teal.shade300,
- Colors.amber.shade300,
- Colors.indigo.shade300,
- ];
-
- final index = categoryName.hashCode % colors.length;
- return colors[index.abs()];
- }
- }
复制代码 月度对比柱状图
响应式柱状图组件
- class IncomeExpenseTrendChart extends ConsumerWidget {
- final DateTimeRange dateRange;
- final int ledgerId;
- const IncomeExpenseTrendChart({
- Key? key,
- required this.dateRange,
- required this.ledgerId,
- }) : super(key: key);
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final dailyStatsAsync = ref.watch(dailyStatsProvider(DailyStatsParams(
- ledgerId: ledgerId,
- range: dateRange,
- )));
- return Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- '收支趋势',
- style: Theme.of(context).textTheme.titleLarge,
- ),
- PopupMenuButton<String>(
- onSelected: (value) {
- // 处理时间范围选择
- },
- itemBuilder: (context) => [
- const PopupMenuItem(value: '7d', child: Text('最近7天')),
- const PopupMenuItem(value: '30d', child: Text('最近30天')),
- const PopupMenuItem(value: '90d', child: Text('最近90天')),
- ],
- child: const Icon(Icons.more_vert),
- ),
- ],
- ),
- const SizedBox(height: 16),
-
- SizedBox(
- height: 280,
- child: dailyStatsAsync.when(
- data: (stats) => _buildChart(context, stats),
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => Center(
- child: Text('加载失败: $error'),
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildChart(BuildContext context, List<DailyStats> stats) {
- if (stats.isEmpty) {
- return const Center(
- child: Text('暂无数据'),
- );
- }
- final theme = Theme.of(context);
- final colors = BeeTheme.colorsOf(context);
- return LineChart(
- LineChartData(
- gridData: FlGridData(
- show: true,
- drawHorizontalLine: true,
- drawVerticalLine: false,
- horizontalInterval: _calculateInterval(stats),
- getDrawingHorizontalLine: (value) => FlLine(
- color: theme.colorScheme.outline.withOpacity(0.2),
- strokeWidth: 1,
- ),
- ),
-
- titlesData: FlTitlesData(
- show: true,
- rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
- topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
-
- bottomTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- reservedSize: 30,
- interval: _getBottomInterval(stats),
- getTitlesWidget: (value, meta) => _buildBottomTitle(
- context,
- stats,
- value.toInt(),
- ),
- ),
- ),
-
- leftTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- interval: _calculateInterval(stats),
- reservedSize: 60,
- getTitlesWidget: (value, meta) => _buildLeftTitle(
- context,
- value,
- ),
- ),
- ),
- ),
-
- borderData: FlBorderData(show: false),
-
- minX: 0,
- maxX: stats.length.toDouble() - 1,
- minY: _getMinY(stats),
- maxY: _getMaxY(stats),
-
- lineBarsData: [
- // 收入线
- LineChartBarData(
- spots: _createSpots(stats, (stat) => stat.income),
- isCurved: true,
- color: colors.income,
- barWidth: 3,
- isStrokeCapRound: true,
- dotData: FlDotData(
- show: true,
- getDotPainter: (spot, percent, barData, index) =>
- FlDotCirclePainter(
- radius: 4,
- color: colors.income,
- strokeWidth: 2,
- strokeColor: Colors.white,
- ),
- ),
- belowBarData: BarAreaData(
- show: true,
- color: colors.income.withOpacity(0.1),
- ),
- ),
-
- // 支出线
- LineChartBarData(
- spots: _createSpots(stats, (stat) => stat.expense),
- isCurved: true,
- color: colors.expense,
- barWidth: 3,
- isStrokeCapRound: true,
- dotData: FlDotData(
- show: true,
- getDotPainter: (spot, percent, barData, index) =>
- FlDotCirclePainter(
- radius: 4,
- color: colors.expense,
- strokeWidth: 2,
- strokeColor: Colors.white,
- ),
- ),
- ),
- ],
-
- lineTouchData: LineTouchData(
- enabled: true,
- touchTooltipData: LineTouchTooltipData(
- tooltipBgColor: theme.colorScheme.surface,
- tooltipBorder: BorderSide(
- color: theme.colorScheme.outline,
- ),
- tooltipRoundedRadius: 8,
- getTooltipItems: (touchedSpots) => _buildTooltipItems(
- context,
- touchedSpots,
- stats,
- colors,
- ),
- ),
- touchCallback: (FlTouchEvent event, LineTouchResponse? touchResponse) {
- // 处理触摸事件
- if (event is FlTapUpEvent && touchResponse?.lineBarSpots != null) {
- final spot = touchResponse!.lineBarSpots!.first;
- final dayStats = stats[spot.spotIndex];
- _showDayDetails(context, dayStats);
- }
- },
- ),
- ),
- );
- }
- List<FlSpot> _createSpots(List<DailyStats> stats, double Function(DailyStats) getValue) {
- return stats.asMap().entries.map((entry) {
- return FlSpot(entry.key.toDouble(), getValue(entry.value));
- }).toList();
- }
- double _calculateInterval(List<DailyStats> stats) {
- if (stats.isEmpty) return 100;
-
- final maxValue = stats
- .map((s) => math.max(s.income, s.expense))
- .reduce(math.max);
-
- if (maxValue <= 100) return 50;
- if (maxValue <= 1000) return 200;
- if (maxValue <= 10000) return 2000;
- return 5000;
- }
- double _getBottomInterval(List<DailyStats> stats) {
- if (stats.length <= 7) return 1;
- if (stats.length <= 14) return 2;
- if (stats.length <= 30) return 5;
- return 10;
- }
- Widget _buildBottomTitle(BuildContext context, List<DailyStats> stats, int index) {
- if (index < 0 || index >= stats.length) return const SizedBox.shrink();
-
- final date = stats[index].date;
- final text = DateFormat('MM/dd').format(date);
-
- return SideTitleWidget(
- axisSide: meta.axisSide,
- child: Text(
- text,
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- fontSize: 12,
- ),
- ),
- );
- }
- Widget _buildLeftTitle(BuildContext context, double value) {
- return Text(
- _formatAmount(value),
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- fontSize: 12,
- ),
- );
- }
- String _formatAmount(double amount) {
- if (amount.abs() >= 10000) {
- return '${(amount / 10000).toStringAsFixed(1)}万';
- }
- return amount.toStringAsFixed(0);
- }
- List<LineTooltipItem?> _buildTooltipItems(
- BuildContext context,
- List<LineBarSpot> touchedSpots,
- List<DailyStats> stats,
- BeeColors colors,
- ) {
- return touchedSpots.map((LineBarSpot touchedSpot) {
- const textStyle = TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 14,
- );
-
- final dayStats = stats[touchedSpot.spotIndex];
- final date = DateFormat('MM月dd日').format(dayStats.date);
-
- if (touchedSpot.barIndex == 0) {
- // 收入线
- return LineTooltipItem(
- '$date\n收入: ${dayStats.income.toStringAsFixed(2)}',
- textStyle.copyWith(color: colors.income),
- );
- } else {
- // 支出线
- return LineTooltipItem(
- '$date\n支出: ${dayStats.expense.toStringAsFixed(2)}',
- textStyle.copyWith(color: colors.expense),
- );
- }
- }).toList();
- }
- void _showDayDetails(BuildContext context, DailyStats dayStats) {
- showModalBottomSheet(
- context: context,
- builder: (context) => DayDetailsSheet(dayStats: dayStats),
- );
- }
- double _getMinY(List<DailyStats> stats) {
- if (stats.isEmpty) return 0;
- return math.min(0, stats.map((s) => math.min(s.income, s.expense)).reduce(math.min)) * 1.1;
- }
- double _getMaxY(List<DailyStats> stats) {
- if (stats.isEmpty) return 100;
- return stats.map((s) => math.max(s.income, s.expense)).reduce(math.max) * 1.1;
- }
- }
复制代码 响应式数据更新
- class CategoryExpensePieChart extends ConsumerStatefulWidget {
- final DateTimeRange dateRange;
- final int ledgerId;
- const CategoryExpensePieChart({
- Key? key,
- required this.dateRange,
- required this.ledgerId,
- }) : super(key: key);
- @override
- ConsumerState<CategoryExpensePieChart> createState() => _CategoryExpensePieChartState();
- }
- class _CategoryExpensePieChartState extends ConsumerState<CategoryExpensePieChart>
- with SingleTickerProviderStateMixin {
- int touchedIndex = -1;
- late AnimationController _animationController;
- late Animation<double> _animation;
- @override
- void initState() {
- super.initState();
- _animationController = AnimationController(
- duration: const Duration(milliseconds: 600),
- vsync: this,
- );
- _animation = CurvedAnimation(
- parent: _animationController,
- curve: Curves.easeInOut,
- );
- _animationController.forward();
- }
- @override
- void dispose() {
- _animationController.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final categoryStatsAsync = ref.watch(categoryStatsProvider(CategoryStatsParams(
- ledgerId: widget.ledgerId,
- range: widget.dateRange,
- type: 'expense',
- )));
- return Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '支出分类',
- style: Theme.of(context).textTheme.titleLarge,
- ),
- const SizedBox(height: 16),
-
- SizedBox(
- height: 300,
- child: categoryStatsAsync.when(
- data: (stats) => _buildChart(context, stats),
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => Center(
- child: Text('加载失败: $error'),
- ),
- ),
- ),
-
- const SizedBox(height: 16),
- categoryStatsAsync.maybeWhen(
- data: (stats) => _buildLegend(context, stats),
- orElse: () => const SizedBox.shrink(),
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildChart(BuildContext context, List<CategoryStats> stats) {
- if (stats.isEmpty) {
- return const Center(
- child: Text('暂无支出数据'),
- );
- }
- // 只显示前8个分类,其余归为"其他"
- final displayStats = _prepareDisplayStats(stats);
- final total = displayStats.fold(0.0, (sum, stat) => sum + stat.value);
- return AnimatedBuilder(
- animation: _animation,
- builder: (context, child) {
- return PieChart(
- PieChartData(
- pieTouchData: PieTouchData(
- touchCallback: (FlTouchEvent event, pieTouchResponse) {
- setState(() {
- if (!event.isInterestedForInteractions ||
- pieTouchResponse == null ||
- pieTouchResponse.touchedSection == null) {
- touchedIndex = -1;
- return;
- }
- touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
- });
- },
- ),
-
- borderData: FlBorderData(show: false),
- sectionsSpace: 2,
- centerSpaceRadius: 60,
-
- sections: displayStats.asMap().entries.map((entry) {
- final index = entry.key;
- final stat = entry.value;
- final isTouched = index == touchedIndex;
- final percentage = (stat.value / total * 100);
-
- return PieChartSectionData(
- color: stat.color,
- value: stat.value,
- title: '${percentage.toStringAsFixed(1)}%',
- radius: (isTouched ? 110.0 : 100.0) * _animation.value,
- titleStyle: TextStyle(
- fontSize: isTouched ? 16.0 : 14.0,
- fontWeight: FontWeight.bold,
- color: Colors.white,
- shadows: [
- Shadow(
- color: Colors.black.withOpacity(0.5),
- blurRadius: 2,
- ),
- ],
- ),
- badgeWidget: isTouched ? _buildBadge(stat) : null,
- badgePositionPercentageOffset: 1.2,
- );
- }).toList(),
- ),
- );
- },
- );
- }
- Widget _buildBadge(CategoryStats stat) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- color: stat.color,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: Colors.white, width: 2),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.2),
- blurRadius: 4,
- offset: const Offset(0, 2),
- ),
- ],
- ),
- child: Text(
- '¥${stat.value.toStringAsFixed(0)}',
- style: const TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 12,
- ),
- ),
- );
- }
- Widget _buildLegend(BuildContext context, List<CategoryStats> stats) {
- final displayStats = _prepareDisplayStats(stats);
-
- return Column(
- children: displayStats.asMap().entries.map((entry) {
- final index = entry.key;
- final stat = entry.value;
- final isHighlighted = index == touchedIndex;
-
- return AnimatedContainer(
- duration: const Duration(milliseconds: 200),
- margin: const EdgeInsets.symmetric(vertical: 2),
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: isHighlighted
- ? stat.color.withOpacity(0.1)
- : Colors.transparent,
- borderRadius: BorderRadius.circular(8),
- border: isHighlighted
- ? Border.all(color: stat.color.withOpacity(0.3))
- : null,
- ),
- child: Row(
- children: [
- Container(
- width: 16,
- height: 16,
- decoration: BoxDecoration(
- color: stat.color,
- shape: BoxShape.circle,
- ),
- ),
- const SizedBox(width: 12),
-
- Expanded(
- child: Text(
- stat.categoryName,
- style: TextStyle(
- fontWeight: isHighlighted
- ? FontWeight.w600
- : FontWeight.normal,
- ),
- ),
- ),
-
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(
- '¥${stat.value.toStringAsFixed(2)}',
- style: TextStyle(
- fontWeight: FontWeight.w600,
- color: isHighlighted
- ? stat.color
- : Theme.of(context).colorScheme.onSurface,
- ),
- ),
- Text(
- '${stat.transactionCount}笔',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }).toList(),
- );
- }
- List<CategoryStats> _prepareDisplayStats(List<CategoryStats> stats) {
- if (stats.length <= 8) return stats;
- final topStats = stats.take(7).toList();
- final othersValue = stats.skip(7).fold(0.0, (sum, stat) => sum + stat.value);
- final othersCount = stats.skip(7).fold(0, (sum, stat) => sum + stat.transactionCount);
- if (othersValue > 0) {
- topStats.add(CategoryStats(
- date: DateTime.now(),
- value: othersValue,
- categoryName: '其他',
- transactionCount: othersCount,
- color: Colors.grey.shade400,
- ));
- }
- return topStats;
- }
- }
复制代码 图表交互增强
手势操作支持
- class MonthlyComparisonBarChart extends ConsumerWidget {
- final int year;
- final int ledgerId;
- const MonthlyComparisonBarChart({
- Key? key,
- required this.year,
- required this.ledgerId,
- }) : super(key: key);
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final monthlyTrendsAsync = ref.watch(monthlyTrendsProvider(MonthlyTrendsParams(
- ledgerId: ledgerId,
- year: year,
- )));
- return Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- '$year年月度对比',
- style: Theme.of(context).textTheme.titleLarge,
- ),
- Row(
- children: [
- _buildLegendItem(context, '收入', BeeTheme.colorsOf(context).income),
- const SizedBox(width: 16),
- _buildLegendItem(context, '支出', BeeTheme.colorsOf(context).expense),
- ],
- ),
- ],
- ),
- const SizedBox(height: 16),
-
- SizedBox(
- height: 300,
- child: monthlyTrendsAsync.when(
- data: (trends) => _buildChart(context, trends),
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => Center(
- child: Text('加载失败: $error'),
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildLegendItem(BuildContext context, String label, Color color) {
- return Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 12,
- height: 12,
- decoration: BoxDecoration(
- color: color,
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- const SizedBox(width: 6),
- Text(
- label,
- style: Theme.of(context).textTheme.bodySmall,
- ),
- ],
- );
- }
- Widget _buildChart(BuildContext context, List<MonthlyTrend> trends) {
- if (trends.isEmpty) {
- return const Center(
- child: Text('暂无数据'),
- );
- }
- final theme = Theme.of(context);
- final colors = BeeTheme.colorsOf(context);
- final maxValue = trends
- .map((t) => math.max(t.income, t.expense))
- .reduce(math.max);
- return BarChart(
- BarChartData(
- alignment: BarChartAlignment.spaceAround,
- maxY: maxValue * 1.2,
-
- gridData: FlGridData(
- show: true,
- drawHorizontalLine: true,
- drawVerticalLine: false,
- horizontalInterval: _calculateInterval(maxValue),
- getDrawingHorizontalLine: (value) => FlLine(
- color: theme.colorScheme.outline.withOpacity(0.2),
- strokeWidth: 1,
- ),
- ),
-
- titlesData: FlTitlesData(
- show: true,
- rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
- topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
-
- bottomTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- getTitlesWidget: (value, meta) {
- final month = value.toInt() + 1;
- return SideTitleWidget(
- axisSide: meta.axisSide,
- child: Text(
- '${month}月',
- style: TextStyle(
- color: theme.colorScheme.onSurfaceVariant,
- fontSize: 12,
- ),
- ),
- );
- },
- ),
- ),
-
- leftTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- reservedSize: 60,
- interval: _calculateInterval(maxValue),
- getTitlesWidget: (value, meta) {
- return Text(
- _formatAmount(value),
- style: TextStyle(
- color: theme.colorScheme.onSurfaceVariant,
- fontSize: 12,
- ),
- );
- },
- ),
- ),
- ),
-
- borderData: FlBorderData(show: false),
-
- barGroups: trends.asMap().entries.map((entry) {
- final index = entry.key;
- final trend = entry.value;
-
- return BarChartGroupData(
- x: index,
- barRods: [
- BarChartRodData(
- toY: trend.income,
- color: colors.income,
- width: 12,
- borderRadius: const BorderRadius.vertical(
- top: Radius.circular(4),
- ),
- backDrawRodData: BackgroundBarChartRodData(
- show: true,
- toY: maxValue * 1.2,
- color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
- ),
- ),
- BarChartRodData(
- toY: trend.expense,
- color: colors.expense,
- width: 12,
- borderRadius: const BorderRadius.vertical(
- top: Radius.circular(4),
- ),
- ),
- ],
- barsSpace: 4,
- );
- }).toList(),
-
- barTouchData: BarTouchData(
- enabled: true,
- touchTooltipData: BarTouchTooltipData(
- tooltipBgColor: theme.colorScheme.surface,
- tooltipBorder: BorderSide(
- color: theme.colorScheme.outline,
- ),
- tooltipRoundedRadius: 8,
- getTooltipItem: (group, groupIndex, rod, rodIndex) {
- final trend = trends[groupIndex];
- final isIncome = rodIndex == 0;
- final amount = isIncome ? trend.income : trend.expense;
- final label = isIncome ? '收入' : '支出';
-
- return BarTooltipItem(
- '${trend.month}月\n$label: ¥${amount.toStringAsFixed(2)}',
- TextStyle(
- color: isIncome ? colors.income : colors.expense,
- fontWeight: FontWeight.bold,
- ),
- );
- },
- ),
- ),
- ),
- );
- }
- double _calculateInterval(double maxValue) {
- if (maxValue <= 1000) return 200;
- if (maxValue <= 10000) return 2000;
- if (maxValue <= 100000) return 20000;
- return 50000;
- }
- String _formatAmount(double amount) {
- if (amount >= 10000) {
- return '${(amount / 10000).toStringAsFixed(1)}万';
- }
- return '${amount.toStringAsFixed(0)}';
- }
- }
复制代码 空状态处理
- class ChartDataCache {
- static final Map<String, CachedData> _cache = {};
- static const Duration cacheExpiration = Duration(minutes: 5);
- static Future<T> getOrCompute<T>(
- String key,
- Future<T> Function() computation,
- ) async {
- final cached = _cache[key];
-
- if (cached != null &&
- DateTime.now().difference(cached.timestamp) < cacheExpiration) {
- return cached.data as T;
- }
- final result = await computation();
- _cache[key] = CachedData(
- data: result,
- timestamp: DateTime.now(),
- );
- return result;
- }
- static void clearCache() {
- _cache.clear();
- }
- static void clearExpired() {
- final now = DateTime.now();
- _cache.removeWhere((key, value) =>
- now.difference(value.timestamp) >= cacheExpiration);
- }
- }
- class CachedData {
- final dynamic data;
- final DateTime timestamp;
- CachedData({
- required this.data,
- required this.timestamp,
- });
- }
复制代码 最佳实践总结
1. 数据处理原则
- 数据分层:原始数据 -> 处理数据 -> 显示数据
- 缓存策略:合理使用缓存避免重复计算
- 异步加载:大数据集使用异步处理
2. 性能优化
- 延迟渲染:复杂图表使用延迟初始化
- 内存管理:及时清理不需要的数据
- 动画优化:合理使用动画,避免过度渲染
3. 用户体验
- 加载状态:提供明确的加载反馈
- 错误处理:优雅处理数据异常
- 交互反馈:提供触觉和视觉反馈
4. 视觉设计
- 颜色一致性:遵循应用主题色彩
- 可读性:确保文字和图形清晰可见
- 响应式:适配不同屏幕尺寸
实际应用效果
在BeeCount项目中,fl_chart数据可视化系统带来了显著价值:
- 用户洞察提升:直观的图表帮助用户理解消费模式
- 使用时长增加:丰富的数据分析提升用户粘性
- 专业印象:美观的图表提升应用专业形象
- 决策支持:数据可视化辅助用户财务决策
结语
数据可视化是现代应用不可或缺的功能,fl_chart为Flutter开发者提供了强大而灵活的图表解决方案。通过合理的架构设计、性能优化和用户体验考虑,我们可以构建出既美观又实用的数据可视化系统。
BeeCount的实践证明,优秀的数据可视化不仅能提升用户体验,更能为用户创造实际价值,帮助他们更好地理解和管理自己的财务状况。
关于BeeCount项目
项目特色
<ul>
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |