热力图点亮坚持:ArkUI 日历热力图让鸿蒙打卡页一眼见自律


实例:习惯打卡日历(Habit)|风格:日历热力图
一、设计理念:把「自律」变成看得见的色块
打卡应用的核心激励逻辑是「看得见的坚持」——用户需要一眼看到自己坚持了多少天、断在哪里、还差多少。实例 5 的页面风格定为「日历热力图」:一个完整的月历网格,已打卡的日期用习惯主题色点亮,未打卡的用浅灰底,整个月历像一块热力图,自律程度一目了然。GitHub 的贡献图、各健身 App 的打卡日历都采用这个视觉隐喻。
页面信息架构:
- 标题栏:打卡日历标题 + 习惯总数 + 刷新;
- 习惯切换标签:横向滚动的习惯胶囊(每个带图标和颜色),点击切换热力图主体;
- 热力图面板:当前习惯的月历网格 + 连续天数徽标 + 本月进度;
- 习惯列表:全部习惯的进度行 + 今日打卡按钮 + 删除。
二、页面状态设计:三个 Map 承载统计
HabitPage 的状态设计比前面实例复杂——因为一个页面要同时展示多个习惯的多种统计。落地版用了三个 Map 分别缓存:
@State checkMap: Map<number, string[]> = new Map(); // habitId -> 已打卡日期集合
@State streakMap: Map<number, number> = new Map(); // habitId -> 连续天数
@State monthMap: Map<number, number> = new Map(); // habitId -> 本月次数
@State selected: number = -1; // 当前查看热力图的习惯
@State monthOffset: number = 0; // 0 本月 / -1 上月…
为什么用 Map 而不是平行数组? 因为习惯列表和热力图面板都要按 habit.id 快速取统计值,Map 的 get(h.id) 是 O(1)。三个 Map 在 loadCheckData() 里一次性批量填充:
async loadCheckData(): Promise<void> {
const checkMap: Map<number, string[]> = new Map();
const streakMap: Map<number, number> = new Map();
const monthMap: Map<number, number> = new Map();
for (const h of this.habits) {
checkMap.set(h.id, await HabitDao.checkDates(this.context, h.id, 90));
streakMap.set(h.id, await HabitDao.currentStreak(this.context, h.id));
monthMap.set(h.id, await HabitDao.monthCount(this.context, h.id));
}
this.checkMap = checkMap;
this.streakMap = streakMap;
this.monthMap = monthMap;
}
注意先构造临时 Map 再整体赋值——@State 的响应式更新要求整个引用替换,原地 set 不触发 UI 刷新。这是 ArkUI 状态管理的一个关键细节。
三、标题栏与习惯切换标签
标题栏沿用统一模式。习惯切换标签是横向滚动胶囊组:
Scroll() {
Row({ space: 8 }) {
ForEach(this.habits, (h: Habit) => {
Text(`${h.icon} ${h.name}`)
.fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.selected === h.id ? h.color : '#FFFFFF')
.fontColor(this.selected === h.id ? Color.White : '#4B5563')
.onClick(() => this.selected = h.id)
}, (h: Habit) => `${h.id}-${h.name}`)
}.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
选中态用习惯自己的主题色做背景(h.color),白色文字;未选中白底灰字。每个习惯胶囊自带颜色标识,视觉上就是一个小色卡。
四、热力图面板:月历网格的生成算法
热力图面板是页面的技术核心,拆成 HabitHeatmap 这个 @Builder(ArkTS 的 UI 复用方法)。先看月历网格是怎么生成的——这是本实例最有算法含量的部分:
private monthGrid(offset: number): DayCell[] {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + offset;
const first = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: DayCell[] = [];
// 上月补位
const lead = first.getDay() === 0 ? 7 : first.getDay();
const prevDays = new Date(year, month, 0).getDate();
for (let i = lead - 1; i >= 1; i--) {
const d = prevDays - i + 1;
const dt = new Date(year, month - 1, d);
const c: DayCell = { day: d, dateStr: this.dateStr(dt), inMonth: false };
cells.push(c);
}
for (let d = 1; d <= daysInMonth; d++) {
const dt = new Date(year, month, d);
const c: DayCell = { day: d, dateStr: this.dateStr(dt), inMonth: true };
cells.push(c);
}
// 下月补位(补到 7 的倍数)
while (cells.length % 7 !== 0) {
const d = cells.length % 7 === 0 ? 0 : 1;
const dt = new Date(year, month + 1, d + (cells.length % 7 === 0 ? 0 : 1) - 1);
const c: DayCell = { day: dt.getDate(), dateStr: this.dateStr(dt), inMonth: false };
cells.push(c);
}
return cells;
}
算法分三步:
- 上月补位:
first.getDay()是本月 1 号是周几(0=周日,1=周一…)。为了日历对齐「周一开头」,前面要补上个月的最后几天。注意getDay() === 0(周日)时补 7 格(整个上上周日到上周六),其他情况补getDay()-1格; - 本月主体:从 1 号循环到月末,逐日生成;
- 下月补位:
cells.length % 7 !== 0时不断补下月日期,直到总格数是 7 的倍数(完整的一周行)。
DayCell 是显式 interface(ArkTS 禁止内联对象类型):
interface DayCell {
day: number;
dateStr: string; // 'YYYY-MM-DD',用于查询打卡状态
inMonth: boolean; // 是否本月(非本月格字色变浅)
}
为什么 inMonth 标记? 补位的格子(上月/下月日期)视觉上要弱化——字色用浅灰(#D1D5DB),且点击不响应打卡(只在 if (cell.inMonth) 时处理点击),避免用户误点到相邻月份的日期。
关于 @Builder 与 const 的坑:早期版本在 @Builder 内部写了 const habit = this.currentHabit() 这种局部变量声明,ArkTS 编译器报 Only UI component syntax can be written here(@Builder 方法体只允许 UI 组件语法)。落地版改为:@Builder 接收参数 HabitHeatmap(habit: Habit | null),在 build 里调用 this.HabitHeatmap(this.currentHabit())——参数传递替代局部变量,绕开语法限制。
五、热力图格子:打卡状态渲染
月历网格渲染成 7 列 Row,每格是「日期数字 + 色块」:
Row() {
ForEach(this.monthGrid(this.monthOffset), (cell: DayCell, idx: number) => {
Column() {
Text(String(cell.day)).fontSize(10).fontColor(cell.inMonth ? '#4B5563' : '#D1D5DB')
Row()
.width(24).height(24).borderRadius(7).margin({ top: 2 })
.backgroundColor(this.isChecked(habit.id, cell.dateStr) ? habit.color : '#F3F4F6')
.opacity(this.isChecked(habit.id, cell.dateStr) ? 1 : 0.5)
}
.layoutWeight(1)
.onClick(() => {
if (cell.inMonth) {
HabitDao.toggleCheck(this.context, habit.id, cell.dateStr).then(async () => {
await this.loadCheckData();
});
}
})
}, (cell: DayCell, idx: number) => `${cell.dateStr}-${idx}`)
}
.width('100%').margin({ top: 6 })
渲染逻辑:isChecked(habit.id, cell.dateStr) 判断该日是否打卡——从 checkMap 的日期数组转成的 Set 里查:
private isChecked(hid: number, dateStr: string): boolean {
const set = this.checkMap.get(hid);
return set !== undefined && set.includes(dateStr);
}
打卡格用习惯色点亮(habit.color),未打卡格浅灰(#F3F4F6)+ 半透明(opacity 0.5)。点击格子直接 toggle 打卡——点亮的格子再点一次就熄灭(取消打卡),与 5-1 文章的 toggleCheck 数据层逻辑对应。这就是热力图「可交互」的完整闭环。
六、面板头部:连续天数与本月进度
热力图面板顶部是统计信息行:
Row() {
Text(`${habit.icon} ${habit.name}`).fontSize(18).fontWeight(FontWeight.Bold)
Blank()
Text(`本月 ${this.monthMap.get(habit.id) ?? 0}/${habit.target} 天`).fontSize(13).fontColor('#6B7280')
}.width('100%')
Row({ space: 16 }) {
Column() {
Text(`${this.streakMap.get(habit.id) ?? 0}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor(habit.color)
Text('连续天数').fontSize(11).fontColor('#9CA3AF')
}
Column() {
Text('今日已打卡').fontSize(11).fontColor('#9CA3AF')
Text(this.isChecked(habit.id, this.dateStr(new Date())) ? '✅ 是' : '⬜ 未打卡')
.fontSize(15).fontWeight(FontWeight.Medium).margin({ top: 4 })
}
}.margin({ top: 12 })
- 连续天数大号加粗 + 习惯主题色,是激励的核心数字(来自
streakMap,即 5-1 的 currentStreak 算法); - 今日已打卡状态实时反映,✅/⬜ 一目了然。
月份切换按钮(◀ ▶)控制 monthOffset,允许回看前几个月(-5 ~ 0):
Row() {
Text('◀').fontSize(18).onClick(() => {
if (this.monthOffset > -5) {
this.monthOffset--;
}
})
Text(this.monthTitle(this.monthOffset)).fontSize(15).fontWeight(FontWeight.Medium)
.layoutWeight(1).textAlign(TextAlign.Center)
Text('▶').fontSize(18).onClick(() => {
if (this.monthOffset < 0) {
this.monthOffset++;
}
})
}.width('100%').margin({ top: 14 })
只能回看 5 个月、不能看未来(< 0 限制)——边界约束防止用户切到空的未来月份。
七、习惯列表:进度行 + 今日打卡按钮
面板下方是全部习惯的列表,每行展示进度并可直接打卡:
ForEach(this.habits, (h: Habit) => {
Row({ space: 12 }) {
Text(h.icon).fontSize(24)
Column({ space: 2 }) {
Text(h.name).fontSize(15).fontWeight(FontWeight.Medium)
Text(`本月 ${this.monthMap.get(h.id) ?? 0}/${h.target} 天 · 连续 ${this.streakMap.get(h.id) ?? 0} 天`)
.fontSize(12).fontColor('#999999')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(this.isChecked(h.id, this.dateStr(new Date())) ? '✅' : '打卡')
.fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.isChecked(h.id, this.dateStr(new Date())) ? '#E5F6EC' : h.color)
.fontColor(this.isChecked(h.id, this.dateStr(new Date())) ? '#059669' : Color.White)
.onClick(() => this.toggleToday(h))
Text('✕').fontSize(16).fontColor('#CCCCCC').margin({ left: 4 })
.onClick(() => this.deleteHabit(h))
}
.width('100%').padding(12).backgroundColor(Color.White).borderRadius(12).margin({ top: 8 })
}, (h: Habit) => `${h.id}-${h.name}`)
打卡按钮的双态:今天已打卡显示绿色「✅」,未打卡显示习惯色「打卡」。toggleToday 调用数据层 toggleCheck(以今天日期),之后 loadCheckData 刷新三个 Map,热力图与列表同步更新。
八、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 极浅蓝灰 |
清新基调 |
| 热力图格 | 24×24 圆角色块 | 打卡点亮/未打浅灰 |
| 连续天数 | 28fp 习惯色加粗 | 激励核心数字 |
| 习惯胶囊 | 选中习惯色底白字 | 多习惯切换 |
| 主色 | 各习惯自带 color | 个性化主题 |
| 卡角 | 12~16 radius | 统一圆角 |
九、文章小结
本篇文章完成了实例 5 的 UI 层:月历热力图(monthGrid 网格算法 + 打卡格点亮)+ 连续天数徽标 + 月份切换 + 习惯列表今日打卡。技术核心是月份网格生成算法(上月补位/本月主体/下月补齐)与「日期字符串 → Set 查打卡状态」的 O(1) 渲染,以及 @Builder 参数传递规避 ArkTS 语法限制。
更多推荐




所有评论(0)