鸿蒙原生项目实战(四):统计图表与日历详情页实战

鸿蒙原生项目实战(四):统计图表与日历详情页实战

本文深入「习惯大师」的统计页与详情页,揭秘如何用纯 ArkTS 组件实现数据可视化图表与日历打卡视图,不依赖任何第三方图表库。


一、前言

在移动应用中,数据可视化是提升用户留存的关键。「习惯大师」需要让用户直观地看到:

  1. 每天的习惯完成率趋势 → 柱状趋势图
  2. 各分类的习惯分布 → 横向条形图
  3. 单个习惯的月度打卡日历 → 日历热力图

由于鸿蒙生态的第三方图表库还不成熟,我们选择纯组件自绘的方案。


二、统计页面结构

Statistics.ets 页面布局:

复制代码
Column
├── 顶部导航栏(← 返回 + 标题)
├── Scroll
│   ├── 月份切换器(‹ 2025年6月 ›)
│   ├── 概览卡片(习惯数 / 总打卡 / 月完成率)
│   ├── 趋势图卡片(每日完成率柱状图)
│   └── 分类分布卡片
│   └── 底部留白

2.1 状态定义

typescript 复制代码
@Entry
@Component
struct Statistics {
  @State year: number = new Date().getFullYear();
  @State month: number = new Date().getMonth() + 1;
  @State dailyStats: DayStats[] = [];
  @State categoryData: CategoryDist[] = [];
  @State totalHabits: number = 0;
  @State totalRecords: number = 0;
  @State monthAvgRate: number = 0;

  private habitManager: HabitManager = HabitManager.getInstance();
}

2.2 数据加载

typescript 复制代码
async loadData(): Promise<void> {
  const habits = await this.habitManager.getAllHabits();
  const records = await this.habitManager.getAllRecords();
  this.totalHabits = habits.length;
  this.totalRecords = records.length;

  this.dailyStats = await this.habitManager.getMonthDailyStats(this.year, this.month);
  this.categoryData = await this.habitManager.getCategoryDistribution();

  // 计算月平均完成率
  let totalRate = 0;
  let countWithData = 0;
  for (const d of this.dailyStats) {
    if (d.total > 0) {
      totalRate += d.rate;
      countWithData++;
    }
  }
  this.monthAvgRate = countWithData > 0 ? totalRate / countWithData : 0;
}

三、月份切换器

typescript 复制代码
Row() {
  Button() {
    Text('‹').fontSize(22).fontColor($r('app.color.primary'))
  }
  .width(32).height(32)
  .backgroundColor('#EBF3FD')
  .borderRadius(16)
  .onClick(() => this.prevMonth());

  Text(`${this.year}年${this.month}月`)
    .fontSize(18).fontWeight(FontWeight.Bold)
    .fontColor($r('app.color.text_primary'))
    .margin({ left: 12, right: 12 });

  Button() {
    Text('›').fontSize(22).fontColor($r('app.color.primary'))
  }
  .width(32).height(32)
  .backgroundColor('#EBF3FD')
  .borderRadius(16)
  .onClick(() => this.nextMonth());
}

限制逻辑nextMonth() 不允许超过当前月:

typescript 复制代码
nextMonth(): void {
  const now = new Date();
  if (this.year === now.getFullYear() && this.month === now.getMonth() + 1) return;
  // ...
}

四、概览卡片

typescript 复制代码
@Builder
overviewCard() {
  Row() {
    this.overviewItem('📋', '习惯数', `${this.totalHabits}`);
    this.overviewItem('✅', '总打卡', `${this.totalRecords}`);
    this.overviewItem('📊', '月完成率', `${Math.round(this.monthAvgRate * 100)}%`);
  }
  .padding(16)
  .backgroundColor($r('app.color.card_bg'))
  .borderRadius(16)
  .shadow({ radius: 4, color: '#1A000000', offsetX: 0, offsetY: 2 })
}

@Builder
overviewItem(icon: string, label: string, value: string) {
  Column() {
    Text(icon).fontSize(24);
    Text(value).fontSize(20).fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.primary')).margin({ top: 6 });
    Text(label).fontSize(12).fontColor($r('app.color.text_secondary')).margin({ top: 2 });
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center);
}

五、趋势图------柱状图纯 UI 实现

这是统计页最核心的组件。我们使用 Row + Column 组合 来模拟柱状图,Scroll 包裹实现横向滚动

5.1 趋势图容器

typescript 复制代码
@Builder
trendChart() {
  Column() {
    Text('每日完成率趋势')
      .fontSize(16).fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .alignSelf(ItemAlign.Start)
      .margin({ bottom: 16 });

    if (this.dailyStats.length === 0) {
      Text('暂无数据').fontSize(14).fontColor($r('app.color.text_secondary')).height(100);
    } else {
      Scroll() {
        Row() {
          ForEach(this.dailyStats, (stat: DayStats) => {
            this.chartBar(stat);
          })
        }
        .height(150)
        .padding({ left: 4, right: 4 })
        .alignItems(VerticalAlign.Bottom);
      }
      .scrollable(ScrollDirection.Horizontal)
      .width('100%');
    }
  }
  .padding(16)
  .backgroundColor($r('app.color.card_bg'))
  .borderRadius(16)
  .shadow({ radius: 4, color: '#1A000000', offsetX: 0, offsetY: 2 })
}

5.2 单个柱条

typescript 复制代码
@Builder
chartBar(stat: DayStats) {
  Column() {
    Column()
      .width(16)
      .height(Math.max(stat.rate * 120, 2))   // ⬅ 核心:高度 = 完成率 × 最大高度
      .backgroundColor(
        stat.rate >= 1 ? $r('app.color.completed') :
        stat.rate >= 0.5 ? $r('app.color.primary') : '#B2BEC3'
      )
      .borderRadius(8);                       // ⬅ 圆角柱条
    Text(`${parseInt(stat.date.split('-')[2])}`)
      .fontSize(10)
      .fontColor($r('app.color.text_secondary'))
      .margin({ top: 4 });
  }
  .alignItems(HorizontalAlign.Center)
  .margin({ right: 6 });
}

5.3 实现原理

复制代码
比例映射:
  rate = completed / total   (0 ~ 1)
  barHeight = rate × 120px   (最低 2px,确保能看到)

颜色语义:
  rate ≥ 1.0  → 绿色(completed) ← 全部完成
  rate ≥ 0.5  → 紫色(primary)   ← 及格
  rate < 0.5  → 灰色              ← 不及格

5.4 为什么不用 Canvas?

ArkUI 确实提供 Canvas 组件,但在本场景中:

方案 优点 缺点
Row + Column 模拟 代码简单、声明式、支持动画 柱条样式有限
Canvas 自绘 样式灵活、可做复杂的图 需要手动管理绘制逻辑、性能不如原生组件

对于简单的柱状图,声明式组件方案足够了,代码可维护性更好。


六、分类分布图

typescript 复制代码
@Builder
categorySection() {
  Column() {
    Text('习惯分类分布')
      .fontSize(16).fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .alignSelf(ItemAlign.Start).margin({ bottom: 12 });

    if (this.categoryData.length === 0) {
      Text('暂无习惯').margin({ top: 20, bottom: 20 });
    } else {
      ForEach(this.categoryData, (item: CategoryDist) => {
        this.categoryBar(item);
      })
    }
  }
  .padding(16)
  .backgroundColor($r('app.color.card_bg'))
  .borderRadius(16)
  .shadow({ radius: 4, color: '#1A000000', offsetX: 0, offsetY: 2 })
}

@Builder
categoryBar(item: CategoryDist) {
  Row() {
    Text(CATEGORY_ICONS[item.category] || '📌').fontSize(20).margin({ right: 10 });
    Text(item.category).fontSize(14).fontColor($r('app.color.text_primary')).width(50);

    Stack() {
      // 灰色背景条
      Row().width('100%').height(18).backgroundColor('#F0F0F5').borderRadius(9);
      // 彩色进度条
      Row()
        .width(`${this.totalHabits > 0 ? item.count / this.totalHabits * 100 : 0}%`)
        .height(18)
        .backgroundColor(CATEGORY_COLORS[item.category] ?? '#DDA0DD')
        .borderRadius(9)
        .alignSelf(ItemAlign.Start);
    }
    .layoutWeight(1).height(18);

    Text(`${item.count}`)
      .fontSize(14).fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .width(30).textAlign(TextAlign.End);
  }
  .width('100%').margin({ bottom: 10 });
}

Stack 实现进度条的技巧

  1. 底层:100% 宽度的灰色条(总背景)
  2. 上层:基于比例计算宽度的彩色条(进度)
  3. 右侧:精确的数字计数

七、习惯详情页------日历打卡视图

HabitDetail.ets 展示单个习惯的详细信息,核心亮点是月度日历热力图

7.1 数据准备

typescript 复制代码
async loadData(): Promise<void> {
  this.habit = await this.habitManager.getHabitById(this.habitId);
  this.streak = await this.habitManager.getStreak(this.habitId);

  const monthRecords = await this.habitManager.getMonthRecords(
    this.habitId, this.currentYear, this.currentMonth);

  // 计算总打卡次数
  const allRecords = await this.habitManager.getAllRecords();
  this.totalCheckins = allRecords.filter(r => r.habitId === this.habitId).length;

  // 生成日历天数数据
  const days = getMonthDays(this.currentYear, this.currentMonth);
  const daysList: CalendarDay[] = [];
  for (let d = 1; d <= days; d++) {
    const dateStr = `${this.currentYear}-${padDay(this.currentMonth)}-${padDay(d)}`;
    let found = monthRecords.some(r => r.date === dateStr && r.count >= 1);
    daysList.push({ day: d, checked: found });
  }
  this.calendarDays = daysList;
}

7.2 日历头部(星期标签)

typescript 复制代码
Row() {
  ForEach(['日', '一', '二', '三', '四', '五', '六'],
    (day: string) => {
      Text(day)
        .fontSize(13)
        .fontColor($r('app.color.text_secondary'))
        .width(`${100 / 7}%`)
        .textAlign(TextAlign.Center);
    })
}
.width('100%')
.margin({ bottom: 8 });

使用 100% / 7 等分宽度,确保七列均匀分布。

7.3 日历网格

typescript 复制代码
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  // 月首空白占位
  if (this.getFirstDayOfMonth() > 0) {
    ForEach(this.range(0, this.getFirstDayOfMonth()), () => {
      Text('').width(`${100 / 7}%`).height(36);
    })
  }
  // 日期格子
  ForEach(this.calendarDays, (item: CalendarDay) => {
    this.calendarDay(item);
  })
}
.width('100%');

月首偏移计算

typescript 复制代码
getFirstDayOfMonth(): number {
  return new Date(this.currentYear, this.currentMonth - 1, 1).getDay();
  // getDay() 返回值:0=周日, 1=周一, ..., 6=周六
}

7.4 日期格子

typescript 复制代码
@Builder
calendarDay(item: CalendarDay) {
  Column() {
    Text(`${item.day}`)
      .fontSize(14)
      .fontColor(item.checked ? Color.White : $r('app.color.text_primary'));
  }
  .width(`${100 / 7}%`)
  .height(36)
  .justifyContent(FlexAlign.Center)
  .backgroundColor(item.checked ? $r('app.color.completed') : Color.Transparent)
  .borderRadius(18)
}

已打卡日期 :绿色圆形背景 + 白色文字

未打卡日期:透明背景 + 深色文字


八、连续打卡天数算法

typescript 复制代码
async getStreak(habitId: string): Promise<number> {
  const habitRecords = (await this.getAllRecords())
    .filter(r => r.habitId === habitId)
    .sort((a, b) => b.date.localeCompare(a.date));

  if (habitRecords.length === 0) return 0;

  let streak = 0;
  const today = new Date();
  for (let i = 0; i < 365; i++) {
    const d = new Date(today);
    d.setDate(d.getDate() - i);
    const dateStr = formatDate(d);
    if (habitRecords.some(r => r.date === dateStr && r.count >= 1)) {
      streak++;
    } else {
      break;  // 中断即停止计数
    }
  }
  return streak;
}

算法要点

  1. 从今天开始,往前回溯最多 365 天
  2. 每一天检查是否有打卡记录(count >= 1
  3. 一旦某天没打卡,立即中断计数
  4. 返回连续天数

九、信息卡片与统计行

typescript 复制代码
@Builder
infoCard(habit: Habit) {
  Column() {
    Row() {
      Text(CATEGORY_ICONS[habit.category] || '📌').fontSize(36).margin({ right: 16 });
      Column() {
        Text(habit.name).fontSize(22).fontWeight(FontWeight.Bold);
        Row() {
          Text(habit.category)
            .fontSize(13).fontColor(Color.White)
            .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            .backgroundColor(CATEGORY_COLORS[habit.category] as ResourceColor)
            .borderRadius(6);
          Text(habit.frequency).fontSize(13).fontColor($r('app.color.text_secondary')).margin({ left: 8 });
        }
      }.layoutWeight(1);
    }
    Text(`创建于 ${formatDateDisplay(habit.createdAt)}`)
      .fontSize(13).fontColor($r('app.color.text_secondary'))
      .alignSelf(ItemAlign.Start).margin({ top: 12 });
  }
  .padding(16).backgroundColor($r('app.color.card_bg')).borderRadius(16)
}

@Builder
statsRow(habit: Habit) {
  Row() {
    this.statItem('🔥', '连续天数', `${this.streak} 天`);
    this.statItem('📅', '总打卡', `${this.totalCheckins} 次`);
  }
  .padding(16).backgroundColor($r('app.color.card_bg')).borderRadius(16)
}

十、删除习惯

typescript 复制代码
deleteHabit(): void {
  AlertDialog.show({
    title: '删除习惯',
    message: `确定要删除「${this.habit?.name}」及其所有打卡记录吗?`,
    primaryButton: { value: '取消', action: () => {} },
    secondaryButton: {
      value: '删除', fontColor: '#FF6B6B',
      action: () => { this.doDelete(); }
    }
  });
}

async doDelete(): Promise<void> {
  await this.habitManager.deleteHabit(this.habitId);
  router.back();  // 删除后返回首页
}

十一、设计模式总结

统计页与详情页共享了一些设计模式:

模式 应用 好处
@Builder 拆分 卡片、柱条、日历格子 组件复用,主 build() 简洁
Builder 参数化 overviewItem(icon, label, value) 灵活配置,减少重复代码
纯组件模拟图表 Row+Column 柱状图、Stack 进度条 无第三方依赖
异步数据驱动 async/await → @State 数据变更自动刷新 UI

十二、下篇预告

最后一篇我们将完成深色主题适配与设置页功能完善

  • 深色模式的资源覆盖机制
  • 设置页数据统计展示
  • 一键重置所有数据
  • 备份恢复扩展能力
  • 全项目 UI 一致性审查

👉 下一篇:深色主题适配与设置页功能完善


本文所有代码片段均来自真实鸿蒙 NEXT 项目「习惯大师」,你可以对照源码阅读效果更佳。

相关推荐
luozhen1101 小时前
图引擎架构原理剖析:GE在昇腾NPU软件栈中的核心角色
华为
李二。2 小时前
鸿蒙原生ArkTS-鸿蒙6.0新特性-3D卡片翻转画廊
3d·华为·harmonyos
TrisighT3 小时前
Electron 窗口切后台,我的轮询怎么停了?排查一下午才发现是浏览器搞的鬼
electron·harmonyos
胡琦博客3 小时前
RNOH x HarmonyOS Core Speech Kit TTS:商品卖点语音播报真机实践
华为·harmonyos
yuegu7773 小时前
HarmonyOS应用<节气通>开发第12篇:设置页开发
华为·harmonyos
IT大白鼠3 小时前
BGP路径选择机制:属性分类、作用解析与选路流程全解
网络·网络协议·华为
李二。3 小时前
鸿蒙 PC 端截图标注工具全解析
华为·harmonyos
特立独行的猫a3 小时前
MQTT Client的Tauri应用移植到 OpenHarmony 鸿蒙 PC/ARM64 实践记录
mqtt·华为·rust·harmonyos·tauri·移植·鸿蒙pc