在这里插入图片描述

这一篇我们只聚焦一件事:
把“每次加油花了多少钱”画成一条趋势曲线。
你会看到项目里为什么把 x 轴设计成“第 N 次记录”,
为什么底部日期要做稀疏显示,
以及 tooltip 如何把一个点还原成一条真实记录。

为了避免文章讲得很玄,
下面所有代码都来自 lib/app/fillup_app.dartAnalyticsPage
每段代码后面我都会紧跟解释。


1. 花费趋势图想解决什么问题

“花费趋势”不是为了炫技。
它对应的是用户非常具体的两个需求:

  • 对比
    今天这次加油是不是明显比平时贵。
  • 定位
    价格波动是油价变了,还是加油量变了。

项目里把“花费趋势”单独做成一张卡片,
并且放在统计概览之后。
原因也很直接:

  • 概览卡先回答“总体情况”。
  • 趋势图再回答“变化过程”。

2. 数据进入图表前:先把记录按时间排好

图表如果不保证时间顺序,
用户会看到“曲线倒着走”。

项目里在 AnalyticsPage 中先生成一个 ordered

        final ordered = List<FillUpEntry>.from(list)..sort((a, b) => a.dateMs.compareTo(b.dateMs));

这里有三个关键点:

  • 为什么不直接 sort 原来的 list
    因为 list 是从 controller 里拿出来的临时结果,
    但在别的页面也可能会以别的顺序展示。
    复制一份再排序,影响面更小。
  • 为什么用 dateMs
    数据库存的是毫秒时间戳,
    排序稳定、比较成本低。
  • 为什么按升序
    趋势图更符合“从过去到现在”的阅读习惯。

3. 生成 x 轴标签:把时间戳格式化成短日期

曲线的 x 轴在这里被设计成“第 i 条记录”。
但为了让用户知道这些点大概落在哪一天,
底部仍然会显示日期。

项目里这样生成 dateLabels

        final df = DateFormat('MM-dd');
        final dateLabels = ordered
            .map((e) => df.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs)))
            .toList(growable: false);

这段实现背后的取舍:

  • MM-dd 而不是 yyyy-MM-dd
    底部空间有限,短日期更容易读。
  • growable: false
    这里的 labels 只生成一次并用于绘制,
    不需要再扩容。
  • dateMs 转 DateTime
    不在数据层做格式化,
    UI 需要什么格式就自己决定。

4. 标签太多怎么办:showEvery 做“稀疏显示”

当记录很多时,
每个点都显示一个日期会直接挤爆底部。

项目里用了一个很朴素的策略:

        final showEvery = (() {
          final s = (dateLabels.length / 4).floor();
          return s <= 0 ? 1 : s;
        })();

读法可以理解成:

  • 大概只显示 1/4 的标签
    length / 4
  • 至少每隔 1 个显示
    列表短的时候不要误伤。

这个策略并不追求完美,
但它足够稳定:

  • 记录少时,标签完整。
  • 记录多时,标签自动变稀疏。

5. 把每条记录映射成点:costPoints 的构造

fl_chart 的折线图核心输入是 List<FlSpot>
项目里把 x 轴设计成“序号”,
y 轴是“某个指标值”。

对应真实代码:

        final costPoints = <FlSpot>[];
        final pricePoints = <FlSpot>[];
        final litersPoints = <FlSpot>[];
        for (int i = 0; i < ordered.length; i++) {
          final e = ordered[i];
          costPoints.add(FlSpot(i.toDouble(), e.totalCost));
          pricePoints.add(FlSpot(i.toDouble(), e.pricePerLiter));
          litersPoints.add(FlSpot(i.toDouble(), e.liters));
        }

这里的“设计味道”其实很浓:

  • 同一个 i,对应同一次加油
    所以花费、油价、油量可以共享同一套 x 轴。
  • x 用 double
    这是 FlSpot 的要求,
    但也顺便让后续“插值/曲线”更自然。
  • totalCost 直接来自数据模型
    不在统计页二次计算,
    避免 liters * price 的浮点误差在 UI 层重复出现。

这一段同时生成了三条曲线的点集。
但是本篇先只讲 costPoints


6. 卡片骨架:标题 + 固定高度的绘制区域

趋势图在 UI 上是一个卡片。
先把容器搭起来,
读起来更清晰。

真实代码如下(从卡片开始摘录):

            Card(
              child: Padding(
                padding: EdgeInsets.all(12.w),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text('花费趋势', style: Theme.of(context).textTheme.titleMedium),
                    SizedBox(height: 12.h),
                    SizedBox(
                      height: 220.h,
                      child: LineChart(

这段代码里你至少应该注意两点:

  • 高度固定为 220.h
    折线图的可读性强依赖高度。
    高度如果跟随内容变化,
    在不同机型上会非常难调。
  • Column + 标题
    让图表不是孤零零一块画布,
    用户知道自己在看什么。

7. LineChartData:把曲线“画出来”的最小配置

LineChart 的核心参数是 LineChartData
项目里把最关键的部分写得很直白:

                        LineChartData(
                          minX: 0,
                          maxX: (dateLabels.length - 1).toDouble(),
                          lineBarsData: <LineChartBarData>[
                            LineChartBarData(
                              spots: costPoints,
                              isCurved: true,
                              dotData: const FlDotData(show: false),
                              barWidth: 3,
                              color: Theme.of(context).colorScheme.primary,
                            ),
                          ],
                          gridData: const FlGridData(show: true),

逐行解释一下这些参数为什么这样选:

  • minX/maxX
    x 的范围明确锁定在 0..length-1
    这样 getTitlesWidget 和 tooltip 的 idx 才能安全对应回 dateLabels/ordered
  • spots: costPoints
    数据输入就是你第 5 节构造的点。
    图表不关心你的业务模型。
  • isCurved: true
    曲线更顺滑。
    对“花费”这种离散数据来说,
    平滑的视觉反馈更友好。
  • dotData: show: false
    默认不画点。
    点太多会糊成一条“毛毛虫”。
    项目选择在触摸时再强调当前点。
  • barWidth: 3
    线太细不易读。
    线太粗会遮挡波动细节。
  • 颜色跟主题走
    主题色让统计页整体更统一。
  • gridData: show: true
    让用户更容易估算“相差多少”。

8. 只显示底部标题:titlesData 的“克制”

趋势图上如果出现四条坐标轴标题,
反而会变得很吵。
项目里非常克制:
只开底部标题。

真实代码如下:

                          titlesData: FlTitlesData(
                            leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                            topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                            rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                            bottomTitles: AxisTitles(
                              sideTitles: SideTitles(
                                showTitles: true,
                                reservedSize: 28,
                                interval: 1,
                                getTitlesWidget: (value, meta) {
                                  final idx = value.round();
                                  if (idx < 0 || idx >= dateLabels.length) {
                                    return const SizedBox.shrink();
                                  }
                                  if (idx != 0 && idx != dateLabels.length - 1 && idx % showEvery != 0) {
                                    return const SizedBox.shrink();
                                  }
                                  return SideTitleWidget(
                                    axisSide: meta.axisSide,
                                    child: Text(dateLabels[idx], style: Theme.of(context).textTheme.labelSmall),
                                  );
                                },
                              ),
                            ),
                          ),

这一段是花费趋势图“可读性”的核心。
我按两个层级拆解:

8.1 为什么只保留 bottomTitles

  • 左边标题通常代表 y 值刻度。
    但项目里为了干净,
    直接把 y 刻度隐藏掉。
  • 你依然可以通过 grid + tooltip 精确读数。

8.2 getTitlesWidget 的防御性

这里连续做了两次 SizedBox.shrink()

  • 越界直接不画。
  • 中间位置再按 showEvery 做稀疏。

另外还保留了两个边界标签:

  • 第一个点(idx == 0)。
  • 最后一个点(idx == length-1)。

这很像“写分页”:
不管怎么省略,
都要让用户知道起点和终点。


9. 边框与网格:用最少元素维持视觉参考

borderData 在项目里直接关掉:

                          borderData: FlBorderData(show: false),

关掉边框的效果是:

  • 卡片自身就是边界。
  • 图里多一层框会显得拥挤。

与之相对,
网格线是打开的。
这让图表仍然有“参考线”。


10. 交互:触摸线 + 当前点

花费趋势图如果只是静态图,
最多只能看个大概。
项目里把触摸交互做得比较完整。

真实代码如下(触摸配置摘录):

                          lineTouchData: LineTouchData(
                            enabled: true,
                            handleBuiltInTouches: true,
                            getTouchedSpotIndicator: (barData, spotIndexes) {
                              final lineColor = Theme.of(context).colorScheme.outline.withOpacity(0.6);
                              final dotColor = barData.color ?? Theme.of(context).colorScheme.primary;
                              return spotIndexes
                                  .map(
                                    (i) => TouchedSpotIndicatorData(
                                      FlLine(color: lineColor, strokeWidth: 1),
                                      FlDotData(
                                        show: true,
                                        getDotPainter: (spot, percent, bar, index) {
                                          return FlDotCirclePainter(
                                            radius: 4,
                                            color: dotColor,
                                            strokeWidth: 2,
                                            strokeColor: Theme.of(context).colorScheme.surface,
                                          );
                                        },
                                      ),
                                    ),
                                  )
                                  .toList();
                            },

这里的“体验点”非常实用:

  • 垂直指示线
    FlLine 让用户知道自己在看哪一个 x。
  • 触摸才出现的点
    默认不画点,触摸时再画,
    既干净又能定位。
  • dot 的描边颜色用 surface
    避免 dot 在深色背景 tooltip 下“糊掉”。

11. Tooltip:把一个点还原成一条记录

趋势图最关键的,是能回答用户:
“这次花费对应哪次加油?”

项目里 tooltip 不是只显示 y 值。
它还显示:

  • 日期(底部同款 label)。
  • 里程。
  • 油量。

对应真实代码:

                            touchTooltipData: LineTouchTooltipData(
                              tooltipBgColor: Theme.of(context).colorScheme.inverseSurface,
                              getTooltipItems: (spots) {
                                return spots.map((s) {
                                  final idx = s.x.round();
                                  final label = (idx >= 0 && idx < dateLabels.length) ? dateLabels[idx] : '';
                                  final meta = (idx >= 0 && idx < ordered.length) ? ordered[idx] : null;
                                  final ext = meta == null ? '' : '\n${meta.odometer.toStringAsFixed(0)} km • ${meta.liters.toStringAsFixed(2)} L';
                                  return LineTooltipItem(
                                    '$label\n¥${s.y.toStringAsFixed(2)}$ext',
                                    const TextStyle(color: Colors.white),
                                  );
                                }).toList();
                              },
                            ),

我建议你重点看 idx 这条链路:

  • 点的 x 是 i.toDouble()
  • tooltip 里用 round() 还原回 i
  • 再用 i 去索引 dateLabelsordered

这也是为什么第 7 节要锁定 minX/maxX
否则出现“图表可滚动/可缩放”的时候,
idx 会出现越界,
tooltip 会变得不可控。

ext 这里用三元表达式也很关键:

  • 防止越界时 meta 为 null 导致字符串拼接异常。
  • tooltip 仍然能显示基础信息。

12. 这张图为什么没有 y 轴刻度

你可能会问:
不显示 y 轴刻度,用户怎么知道大概多少钱?

项目里用的是“组合方案”:

  • grid 提供大概参照
    让用户看出涨跌幅度。
  • tooltip 提供精确读数
    点击某次加油,直接显示到两位小数。

这种设计对“花费”特别合适。
因为用户更关心“某次具体值”,
而不是每一个 y 刻度。


13. 小结

这一篇我们把“花费趋势图”拆成四步:

  • 先把记录按时间排序得到 ordered
  • 生成 dateLabels 并用 showEvery 稀疏显示
  • 把每条记录映射成 costPoints
  • LineTouchData + tooltip 让每个点都能解释得清楚

下一篇我们继续写“油价/油量趋势”的双曲线,
以及如何让两条曲线在同一张图里保持可读性。


文章底部添加社区引导:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐