在这里插入图片描述
在这里插入图片描述

实例:习惯打卡日历(Habit)|风格:日历热力图

一、设计理念:把「自律」变成看得见的色块

打卡应用的核心激励逻辑是「看得见的坚持」——用户需要一眼看到自己坚持了多少天、断在哪里、还差多少。实例 5 的页面风格定为「日历热力图」:一个完整的月历网格,已打卡的日期用习惯主题色点亮,未打卡的用浅灰底,整个月历像一块热力图,自律程度一目了然。GitHub 的贡献图、各健身 App 的打卡日历都采用这个视觉隐喻。

页面信息架构:

  1. 标题栏:打卡日历标题 + 习惯总数 + 刷新;
  2. 习惯切换标签:横向滚动的习惯胶囊(每个带图标和颜色),点击切换热力图主体;
  3. 热力图面板:当前习惯的月历网格 + 连续天数徽标 + 本月进度;
  4. 习惯列表:全部习惯的进度行 + 今日打卡按钮 + 删除。

二、页面状态设计:三个 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;
}

算法分三步

  1. 上月补位first.getDay() 是本月 1 号是周几(0=周日,1=周一…)。为了日历对齐「周一开头」,前面要补上个月的最后几天。注意 getDay() === 0(周日)时补 7 格(整个上上周日到上周六),其他情况补 getDay()-1 格;
  2. 本月主体:从 1 号循环到月末,逐日生成;
  3. 下月补位cells.length % 7 !== 0 时不断补下月日期,直到总格数是 7 的倍数(完整的一周行)。

DayCell 是显式 interface(ArkTS 禁止内联对象类型):

interface DayCell {
  day: number;
  dateStr: string;   // 'YYYY-MM-DD',用于查询打卡状态
  inMonth: boolean;  // 是否本月(非本月格字色变浅)
}

为什么 inMonth 标记? 补位的格子(上月/下月日期)视觉上要弱化——字色用浅灰(#D1D5DB),且点击不响应打卡(只在 if (cell.inMonth) 时处理点击),避免用户误点到相邻月份的日期。

关于 @Builderconst 的坑:早期版本在 @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 语法限制。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐