📊 鸿蒙原生应用实战(三):历史记录与日历热力图
系列目录
- 第1篇:项目初始化与首页开发
- 第2篇:训练详情页与计时器功能
- 第3篇:历史记录与日历热力图 ←当前
- 第4篇:身体数据记录与趋势分析
- 第5篇:设置页面与项目总结
一、前言
上一篇我们完成了训练详情页和计时器功能,用户已经可以完成一次完整的训练了。但一个合格的健身 App 还需要让用户看到自己的训练历史——数据可视化是养成运动习惯的关键驱动力。
本篇我们开发历史记录页面,包含三个核心模块:
- 本月统计概览(训练次数、总时长、连续天数)
- 月历热力图(日期网格 + 训练标记)
- 周训练时长柱状图
二、页面整体设计
┌────────────────────────────────────┐ │ ← 返回 📊 历史记录 │ ← 顶部导航 ├────────────────────────────────────┤ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │📅 本月 │ │⏱️ 总时长│ │🔥 连续 │ │ ← 统计卡片 │ │ 15次 │ │ 480分钟 │ │ 5天 │ │ │ └────────┘ └────────┘ └────────┘ │ ├────────────────────────────────────┤ │ ← 2026年6月 → │ ← 月份切换 │ 日 一 二 三 四 五 六 │ │ 1🏋️ 2 3🏋️ 4 5🏋️ 6 │ │ 7🏋️ 8 9🏋️ 10 11🏋️ 12 13🏋️ │ ← 日历网格 │ 14 15🏋️ 16 17🏋️ 18 ... │ │ ... │ ├────────────────────────────────────┤ │ 📈 本周训练时长(分钟) │ │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ ← 柱状图 │ │45│ │30│ │50│ │40│ │60│ │35│ │ │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │ │ 一 二 三 四 五 六 日 │ └────────────────────────────────────┘三、状态管理
页面需要管理以下状态:
@StatecurrentYear:number=2026;@StatecurrentMonth:number=6;@StatemonthData:MonthDay[]=[];@StateworkoutRecords:Record<string,boolean>={};@StateweeklyStats:number[]=[0,0,0,0,0,0,0];@StatetotalWorkouts:number=0;@StatetotalMinutes:number=0;@StatestreakDays:number=0;MonthDay接口定义:
interfaceMonthDay{day:number;// 日期数字(0 表示空白占位)hasWorkout:boolean;// 当天是否有训练}四、月历组件实现
月历是历史记录页面的核心组件,需要处理:
- 计算某月有多少天
- 计算当月1号是星期几(用于填充空白)
- 渲染日期网格
- 月份切换
4.1 生成月历数据
loadMonthData():void{constdaysInMonth=newDate(this.currentYear,this.currentMonth,0).getDate();constfirstDayOfWeek=newDate(this.currentYear,this.currentMonth-1,1).getDay();constdata:MonthDay[]=[];// 模拟训练记录(这里实际应该从本地数据库读取)for(leti=1;i<=daysInMonth;i++){this.workoutRecords[`${this.currentYear}-${this.currentMonth}-${i}`]=i%2===0;}// 填充月初空白for(leti=0;i<firstDayOfWeek;i++){data.push({day:0,hasWorkout:false});}// 填充日期for(leti=1;i<=daysInMonth;i++){data.push({day:i,hasWorkout:this.workoutRecords[`${this.currentYear}-${this.currentMonth}-${i}`]||false});}this.monthData=data;}关键点:new Date(year, month, 0).getDate()可以获取某月的总天数。比如new Date(2026, 6, 0).getDate()返回 30(6月有30天)。
4.2 月份切换
prevMonth():void{if(this.currentMonth===1){this.currentYear--;this.currentMonth=12;}else{this.currentMonth--;}this.loadMonthData();}nextMonth():void{if(this.currentMonth===12){this.currentYear++;this.currentMonth=1;}else{this.currentMonth++;}this.loadMonthData();}4.3 星期头
Row(){ForEach(['日','一','二','三','四','五','六'],(day:string)=>{Text(day).fontSize(13).fontColor('#999').width('14.28%')// 100% / 7.textAlign(TextAlign.Center)})}.width('100%')每个星期占14.28%(100% ÷ 7),用ForEach循环渲染。
4.4 日期网格
使用Grid组件渲染 7 列网格:
Grid(){ForEach(this.monthData,(item:MonthDay)=>{GridItem(){if(item.day>0){Column(){Text(`${item.day}`).fontSize(14).fontColor(item.hasWorkout?'#FFFFFF':'#333')if(item.hasWorkout){Text('🏋️').fontSize(10).margin({top:2})}}.width('100%').aspectRatio(1)// 保持正方形.backgroundColor(item.hasWorkout?'#FF6B35':'transparent').borderRadius(8)}}})}.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')// 7等分.rowsGap(4).columnsGap(4)⚠️踩坑:
columnsTemplate使用1fr单位表示等分比例,7 个1fr就是 7 等分。不能用百分比,否则布局会异常。
热力图效果:有训练记录的日期显示橙色背景 + 白色文字 + 🏋️ 图标,没有训练的日期透明显示。这就是"热力图"的视觉概念——颜色越深表示有活动。
五、统计卡片
页面顶部的三个统计卡片,用@Builder封装:
@BuilderstatCard(icon:string,label:string,value:string,unit:string){Column(){Text(`${icon}${value}`).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FF6B35')Text(unit).fontSize(12).fontColor('#999')Text(label).fontSize(12).fontColor('#666')}.width('30%').padding(12).backgroundColor('#FFFFFF').borderRadius(12).shadow({radius:2,color:'rgba(0,0,0,0.06)'}).alignItems(HorizontalAlign.Center)}三张卡片并排布局:
Row(){this.statCard('📅','本月训练',`${this.totalWorkouts}`,'次')this.statCard('⏱️','总时长',`${this.totalMinutes}`,'分钟')this.statCard('🔥','连续天数',`${this.streakDays}`,'天')}.width('100%').justifyContent(FlexAlign.SpaceEvenly).padding(12)六、周柱状图
柱状图是 ArkTS 中比较有技巧性的实现——鸿蒙原生 SDK 没有内置图表组件,我们需要用 Column 组件模拟柱状图。
6.1 实现思路
每个柱子是一个Column容器,内部嵌一个子Column作为柱体,柱体的高度通过layoutWeight控制:
ForEach(['一','二','三','四','五','六','日'],(day:string,index:number)=>{Column(){Column(){Text('')// 占位,柱体由 layoutWeight 撑开.width('100%').layoutWeight(this.weeklyStats[index])}.width('100%').height(100)// 固定容器高度.backgroundColor('#FFF0E8').borderRadius(6).justifyContent(FlexAlign.End)Text(day).fontSize(11).fontColor('#999').margin({top:4})}.width('12%').alignItems(HorizontalAlign.Center)})原理:外部Column固定height: 100,内部子 Column 的layoutWeight等于训练分钟数,数值越大柱体越高。
6.2 数据模拟
this.weeklyStats=[45,30,50,0,40,60,35];// 分别对应 周一 到 周日 的训练分钟数七、颜色与视觉设计
7.1 配色方案
| 元素 | 颜色 | 用途 |
|---|---|---|
#FF6B35 | 🟠 橙色 | 主题色、训练标记、选中态 |
#FFFFFF | ⬜ 白色 | 卡片背景、有训练日期文字 |
#F5F5F5 | ⬜ 浅灰 | 页面背景 |
#333333 | ⬛ 深灰 | 主要文字 |
#999999 | 🔘 中灰 | 次要文字、辅助信息 |
#FFF0E8 | 🟠 浅橙 | 柱状图背景 |
7.2 阴影与圆角
HarmonyOS 的卡片设计强调圆角 + 阴影的质感:
.backgroundColor('#FFFFFF').borderRadius(16).shadow({radius:4,color:'rgba(0,0,0,0.08)'})borderRadius控制圆角大小,shadow控制阴影扩散半径和颜色。
八、踩坑总结
8.1clip方法已弃用
我一开始用.clip(new Rect(...))给柱状图做裁剪,但 SDK 提示该方法已弃用。实际上设置borderRadius后,子组件会自动裁剪到圆角范围内,不需要额外裁剪。
8.2 Grid 的 columnsTemplate
columnsTemplate一定要和实际列数匹配。7 列就用 7 个1fr,不要用repeat(7, 1fr)之类的 CSS 语法——ArkTS 不支持。
8.3 模拟数据 vs 真实数据
目前历史记录的数据是写死的模拟数据:
// 模拟:逢双数日有训练this.workoutRecords[`${year}-${month}-${i}`]=i%2===0;真实项目中应该从本地数据库(如@ohos.data.preferences或 SQLite)读取。在后续的优化篇中,我们会接入数据持久化。
九、下篇预告
下一篇我们开发身体数据记录页面,实现:
- 体重、体脂率、BMI 实时展示
- 新增记录弹窗
- 历史记录列表
- 趋势自动判断(下降/上升/持平)
下一篇:身体数据记录与趋势分析 →