鸿蒙原生项目实战(四):统计图表与日历详情页实战
本文深入「习惯大师」的统计页与详情页,揭秘如何用纯 ArkTS 组件实现数据可视化图表与日历打卡视图,不依赖任何第三方图表库。
一、前言
在移动应用中,数据可视化是提升用户留存的关键。「习惯大师」需要让用户直观地看到:
- 每天的习惯完成率趋势 → 柱状趋势图
- 各分类的习惯分布 → 横向条形图
- 单个习惯的月度打卡日历 → 日历热力图
由于鸿蒙生态的第三方图表库还不成熟,我们选择纯组件自绘的方案。
二、统计页面结构
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 实现进度条的技巧:
- 底层:100% 宽度的灰色条(总背景)
- 上层:基于比例计算宽度的彩色条(进度)
- 右侧:精确的数字计数
七、习惯详情页------日历打卡视图
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;
}
算法要点:
- 从今天开始,往前回溯最多 365 天
- 每一天检查是否有打卡记录(
count >= 1) - 一旦某天没打卡,立即中断计数
- 返回连续天数
九、信息卡片与统计行
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 项目「习惯大师」,你可以对照源码阅读效果更佳。