ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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


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

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

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

页面信息架构:

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

二、页面状态设计:三个 Map 承载统计

HabitPage 的状态设计比前面实例复杂——因为一个页面要同时展示多个习惯的多种统计。落地版用了三个 Map 分别缓存:

@StatecheckMap:Map<number,string[]>=newMap();// habitId -> 已打卡日期集合@StatestreakMap:Map<number,number>=newMap();// habitId -> 连续天数@StatemonthMap:Map<number,number>=newMap();// habitId -> 本月次数@Stateselected:number=-1;// 当前查看热力图的习惯@StatemonthOffset:number=0;// 0 本月 / -1 上月…

为什么用 Map 而不是平行数组?因为习惯列表和热力图面板都要按habit.id快速取统计值,Map 的get(h.id)是 O(1)。三个 Map 在loadCheckData()里一次性批量填充:

asyncloadCheckData():Promise<void>{constcheckMap:Map<number,string[]>=newMap();conststreakMap:Map<number,number>=newMap();constmonthMap:Map<number,number>=newMap();for(consthofthis.habits){checkMap.set(h.id,awaitHabitDao.checkDates(this.context,h.id,90));streakMap.set(h.id,awaitHabitDao.currentStreak(this.context,h.id));monthMap.set(h.id,awaitHabitDao.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 复用方法)。先看月历网格是怎么生成的——这是本实例最有算法含量的部分:

privatemonthGrid(offset:number):DayCell[]{constnow=newDate();constyear=now.getFullYear();constmonth=now.getMonth()+offset;constfirst=newDate(year,month,1);constdaysInMonth=newDate(year,month+1,0).getDate();constcells:DayCell[]=[];// 上月补位constlead=first.getDay()===0?7:first.getDay();constprevDays=newDate(year,month,0).getDate();for(leti=lead-1;i>=1;i--){constd=prevDays-i+1;constdt=newDate(year,month-1,d);constc:DayCell={day:d,dateStr:this.dateStr(dt),inMonth:false};cells.push(c);}for(letd=1;d<=daysInMonth;d++){constdt=newDate(year,month,d);constc:DayCell={day:d,dateStr:this.dateStr(dt),inMonth:true};cells.push(c);}// 下月补位(补到 7 的倍数)while(cells.length%7!==0){constd=cells.length%7===0?0:1;constdt=newDate(year,month+1,d+(cells.length%7===0?0:1)-1);constc:DayCell={day:dt.getDate(),dateStr:this.dateStr(dt),inMonth:false};cells.push(c);}returncells;}

算法分三步

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

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

interfaceDayCell{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()=>{awaitthis.loadCheckData();});}})},(cell:DayCell,idx:number)=>`${cell.dateStr}-${idx}`)}.width('100%').margin({top:6})

渲染逻辑isChecked(habit.id, cell.dateStr)判断该日是否打卡——从checkMap的日期数组转成的 Set 里查:

privateisChecked(hid:number,dateStr:string):boolean{constset=this.checkMap.get(hid);returnset!==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(newDate()))?'✅ 是':'⬜ 未打卡').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(newDate()))?'✅':'打卡').fontSize(13).padding({left:12,right:12,top:6,bottom:6}).borderRadius(14).backgroundColor(this.isChecked(h.id,this.dateStr(newDate()))?'#E5F6EC':h.color).fontColor(this.isChecked(h.id,this.dateStr(newDate()))?'#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 语法限制。

返回列表