鸿蒙原生应用实战(四):交互进阶 — 日历视图与数据统计页面
鸿蒙原生应用实战(四):交互进阶 — 日历视图与数据统计页面
本文是系列第四篇,深入「心情日记」中两个交互最复杂的页面:日历视图(CalendarPage)和数据统计(StatsPage)。将讲解月历算法、Grid 网格布局、柱状图实现、7日心情趋势等核心功能。
一、日历视图(CalendarPage.ets)全面拆解
日历页面是本应用交互最复杂的页面,它需要:
- 显示指定月份的完整日历网格
- 在有日记的日期上显示心情 Emoji
- 支持月份切换(上/下月)
- 点击日期查看当日日记详情
- 支持删除日记
1.1 页面布局结构
┌──────────────────────────────────────────┐
│ < 返回 心情日历 │
├──────────────────────────────────────────┤
│ ◀ 2025年1月 ▶ │ ← 月份导航
│ │
│ 一 二 三 四 五 六 日 │ ← 星期行
│ │
│ 1 2 3 4 5 6 │
│ 7 8 9 10 11 12 13 │
│ 14 15 16 17 18 19 20 😊 │ ← 日期网格+心情
│ 21 😌 22 😢 23 😴 24 🤩 25 🙏 26 😰 27 😊 │
│ 28 29 30 31 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 😊 2025年1月20日 │ │
│ │ 发年终奖了 │ │ ← 日记详情
│ │ 今天公司发了年终奖... │ │
│ │ #工作 #家庭 │ │
│ │ [ 删除 ] │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
1.2 状态变量
@State entries: DiaryEntry[] = []; // 所有日记数据
@State currentYear: number = 2025; // 当前显示年份
@State currentMonth: number = 1; // 当前显示月份
@State selectedDate: string = ''; // 选中日期 "2025-01-20"
@State selectedEntry: DiaryEntry | undefined; // 选中日期的日记
@State calendarDays: CalendarDay[] = []; // 日历网格数据
@State monthLabel: string = ''; // "2025年1月"
1.3 CalendarDay 接口
interface CalendarDay {
day: number; // 日期数字(上月的为0)
dateStr: string; // "2025-01-20"(上月为"")
hasEntry: boolean; // 是否有日记
mood: MoodLevel; // 心情(无日记则为NEUTRAL)
isEmpty: boolean; // 是否为空位(上月日期)
}
1.4 月历生成算法(核心难点)
buildCalendar(): void {
this.monthLabel = this.currentYear + '年' + this.currentMonth + '月';
// 1. 获取本月第一天和最后一天
let firstDay = new Date(this.currentYear, this.currentMonth - 1, 1);
let lastDay = new Date(this.currentYear, this.currentMonth, 0);
let totalDays = lastDay.getDate();
// 2. 计算第一天是星期几(1=周一, 7=周日)
let startWeekday = firstDay.getDay() || 7;
// getDay() 返回 0=周日,1=周一,...,6=周六
// 我们需要 1=周一,...,7=周日, 所以用 || 7 把 0 转为 7
let days: CalendarDay[] = [];
// 3. 上月补位:当月1号之前填充空位
for (let i = 1; i < startWeekday; i++) {
days.push({
day: 0, dateStr: '', hasEntry: false,
mood: MoodLevel.NEUTRAL, isEmpty: true
});
}
// 4. 本月日期循环
for (let d = 1; d <= totalDays; d++) {
let ds = `${this.currentYear}-${this.currentMonth.toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
let mood = MoodLevel.NEUTRAL;
let hasEntry = false;
// 查找该日期是否有日记
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].date === ds) {
mood = this.entries[i].mood;
hasEntry = true;
break;
}
}
days.push({
day: d, dateStr: ds, hasEntry: hasEntry,
mood: mood, isEmpty: false
});
}
this.calendarDays = days;
// 5. 默认选中今天(如果显示的是本月)
let today = new Date();
if (this.currentYear === today.getFullYear() && this.currentMonth === today.getMonth() + 1) {
let todayStr = `${today.getFullYear()}-${(today.getMonth() + 1).toString().padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`;
this.selectDate(todayStr);
}
}
算法图解:
假设本月1号是星期四(getDay()=4, startWeekday=4)
需要补位:1到3(周一~周三)= 3个空位
星期一 星期二 星期三 星期四 星期五 星期六 星期日
空位 空位 空位 1号 2号 3号 4号
↓
startWeekday = 4
补位 i = 1,2,3
1.5 月份切换
prevMonth(): void {
this.currentMonth--;
if (this.currentMonth < 1) {
this.currentMonth = 12;
this.currentYear--;
}
this.buildCalendar(); // 重新生成日历
}
nextMonth(): void {
this.currentMonth++;
if (this.currentMonth > 12) {
this.currentMonth = 1;
this.currentYear++;
}
this.buildCalendar();
}
边界处理:
- 1月减1 → 变为去年12月
- 12月加1 → 变为明年1月
1.6 Grid 网格渲染
// 星期行
Row() {
ForEach(this.weekDays, (day: string) => {
Text(day).fontSize(13).fontColor('#999999')
.layoutWeight(1).textAlign(TextAlign.Center)
}, (day: string) => day)
}
.width('94%')
// 日期网格
Grid() {
ForEach(this.calendarDays, (cd: CalendarDay) => {
GridItem() {
if (cd.isEmpty) {
Text('').width('100%').height(44) // 空位占位
} else {
Column() {
Text(cd.day.toString())
.fontSize(15)
.fontColor(this.selectedDate === cd.dateStr ? '#FFFFFF'
: (cd.hasEntry ? '#333333' : '#BBBBBB'))
.fontWeight(this.selectedDate === cd.dateStr ? FontWeight.Bold : FontWeight.Normal)
if (cd.hasEntry) {
Text(getMoodInfo(cd.mood).icon).fontSize(14).margin({ top: 1 })
}
}
.width('100%').height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(this.selectedDate === cd.dateStr ? '#6C63FF' : 'transparent')
.borderRadius(8)
}
}
.onClick(() => {
if (!cd.isEmpty && cd.hasEntry) {
this.selectDate(cd.dateStr);
}
})
}, (cd: CalendarDay) => cd.dateStr ? cd.dateStr : Math.random().toString())
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr') // 7列
.columnsGap(2)
.rowsGap(4)
.width('94%')
渲染关键细节:
| 场景 | 日期颜色 | 背景色 | 额外内容 |
|---|---|---|---|
| 选中日期 | 白色 | 紫色 #6C63FF | 粗体 |
| 有日记未选中 | 深色 | 透明 | 心情 Emoji |
| 无日记 | 浅灰 | 透明 | 无 |
| 空位(上月) | 无 | 无 | 占位块 |
1.7 日记详情与删除
// 选中日记时展示详情
if (this.selectedEntry) {
Column() {
Row() {
Text(getMoodInfo(this.selectedEntry.mood).icon).fontSize(28)
Text(getDateLabel(this.selectedEntry.date)).fontSize(16).fontWeight(FontWeight.Bold)
}
Text(this.selectedEntry.title).fontSize(18).fontWeight(FontWeight.Bold)
Text(this.selectedEntry.content).fontSize(15).fontColor('#666666')
// 标签
Row() {
ForEach(this.selectedEntry.tags.split(','), (tag: string) => {
Text('#' + tag.trim()).fontSize(12).fontColor('#6C63FF')
.backgroundColor('#EEEAFF').borderRadius(8)
}, (tag: string) => tag)
}
// 删除按钮
Button('删除').width(80).height(32)
.backgroundColor('#FFFFFF')
.fontColor('#FF4757')
.border({ width: 1, color: '#FF4757' })
.borderRadius(16)
.onClick(() => { this.deleteEntry(this.selectedEntry!.id); })
}
.padding(16).backgroundColor('#FFFFFF').borderRadius(12)
}
// 选中日期但无日记
if (this.selectedDate && !this.selectedEntry) {
Column() {
Text('这一天没有日记').fontSize(16).fontColor('#CCCCCC')
Button('写一篇').fontSize(14).fontColor('#6C63FF')
.backgroundColor('#EEEAFF').borderRadius(18)
.onClick(() => { router.pushUrl({ url: 'pages/WritePage' }); })
}
}
1.8 删除功能
deleteEntry(id: string): void {
let newList: DiaryEntry[] = [];
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].id !== id) {
newList.push(this.entries[i]);
}
}
this.entries = newList;
AppStorage.set<DiaryEntry[]>('entries', newList);
this.selectedEntry = undefined;
this.selectedDate = '';
this.buildCalendar(); // 重新构建日历
}
三件事别忘了:
- 更新 AppStorage
- 清空选中状态
- 重新构建日历网格
二、数据统计页面(StatsPage.ets)全面拆解
2.1 页面功能
┌──────────────────────────────────────────┐
│ < 返回 数据统计 │
├──────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ │
│ │ 📝 │ │ 📅 │ │
│ │ 15 │ │ 5 │ │ ← 概览卡片
│ │ 总日记 │ │ 本月 │ │
│ └──────────┘ └──────────┘ │
│ │
│ 心情分布 │
│ 😊 开心 ████████████ 5次 │
│ 😌 平静 ██████████ 4次 │ ← 水平柱状图
│ 😢 难过 █████ 3次 │
│ 😠 生气 ██ 1次 │
│ ... │
│ │
│ 近7天心情 │
│ 😊 😌 😢 - 🤩 - 🙏 │ ← 心情趋势
│ 1/20 1/21 1/22 1/23 1/24 1/25 1/26 │
└──────────────────────────────────────────┘
2.2 状态变量与接口
interface MoodCount {
level: MoodLevel;
icon: string;
label: string;
color: string;
count: number;
}
interface WeekMood {
dateLabel: string; // "1/20"
mood: MoodLevel;
hasData: boolean;
}
@State entries: DiaryEntry[] = [];
@State totalCount: number = 0;
@State thisMonthCount: number = 0;
@State maxCount: number = 0;
@State moodStats: MoodCount[] = [];
@State weekMoods: WeekMood[] = [];
2.3 统计计算逻辑
calcStats(): void {
this.totalCount = this.entries.length;
// 本月统计
let now = new Date();
let thisMonth = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`;
let monthCount = 0;
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].date.indexOf(thisMonth) === 0) {
monthCount++; // 日期以 "2025-01" 开头的即为本月
}
}
this.thisMonthCount = monthCount;
// 各心情统计
let moods = getMoods(); // 9种心情
let counts: MoodCount[] = [];
let maxC = 0;
for (let i = 0; i < moods.length; i++) {
let m = moods[i];
let cnt = 0;
for (let j = 0; j < this.entries.length; j++) {
if (this.entries[j].mood === m.level) {
cnt++;
}
}
if (cnt > maxC) maxC = cnt;
let mc: MoodCount = {
level: m.level, icon: m.icon, label: m.label,
color: m.color, count: cnt
};
counts.push(mc);
}
// 按次数降序排列
counts.sort((a, b) => b.count - a.count);
this.moodStats = counts;
this.maxCount = maxC; // 用于计算柱状图宽度比例
// 近7天心情
let weekMoodsArr: WeekMood[] = [];
for (let d = 6; d >= 0; d--) {
let date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - d);
let ds = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
let dayLabel = (date.getMonth() + 1) + '/' + date.getDate();
let foundMood = MoodLevel.NEUTRAL;
let hasData = false;
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].date === ds) {
foundMood = this.entries[i].mood;
hasData = true;
break;
}
}
weekMoodsArr.push({ dateLabel: dayLabel, mood: foundMood, hasData: hasData });
}
this.weekMoods = weekMoodsArr;
}
2.4 水平柱状图实现
这是 ArkTS 中实现柱状图的一个巧妙方法——使用内嵌 Row 的宽度百分比:
ForEach(this.moodStats, (ms: MoodCount) => {
if (ms.count > 0) {
Row() {
Text(ms.icon).fontSize(18).width(32)
Text(ms.label).fontSize(14).fontColor('#666666').width(40)
// 柱状图主体
Row() {
Text('')
.height(18)
.width((this.maxCount > 0 ? ((ms.count / this.maxCount) * 100) : 0) + '%')
.backgroundColor(ms.color)
.borderRadius(4)
}
.width('100%')
.backgroundColor('#F0F0F0')
.borderRadius(4)
.layoutWeight(1) // 填充剩余空间
.margin({ left: 6, right: 6 })
Text(ms.count + '次').fontSize(13).fontColor('#999999').width(40).textAlign(TextAlign.End)
}
.width('100%')
.margin({ bottom: 8 })
}
}, (ms: MoodCount) => ms.level)
柱状图实现原理:
外容器宽度 = 100%(充满剩余空间)
柱状条宽度 = (次数 / 最大次数) × 100%
例如:最大次数=5
- 开心5次 → 100% ████████████████
- 平静4次 → 80% ██████████████
- 难过3次 → 60% ██████████
- 生气1次 → 20% ████
优点:
- 纯 ArkTS 实现,无需第三方图表库
- 响应式,自动适配屏幕宽度
- 颜色与心情对应,视觉直观
2.5 7天心情趋势
Row() {
ForEach(this.weekMoods, (wm: WeekMood) => {
Column() {
if (wm.hasData) {
Text(getMoodInfo(wm.mood).icon).fontSize(24)
} else {
Text('-').fontSize(20).fontColor('#DDDDDD')
}
Text(wm.dateLabel).fontSize(10).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1) // 7列等宽
.alignItems(HorizontalAlign.Center)
}, (wm: WeekMood) => wm.dateLabel)
}
.width('100%')
数据构成:
- 从今天往前推 7 天(包括今天)
- 有日记 → 显示心情 Emoji
- 无日记 → 显示
-
2.6 概览卡片
@Builder overviewBox(icon: string, label: string, value: string) {
Column() {
Text(icon).fontSize(24)
Text(value).fontSize(28).fontWeight(FontWeight.Bold)
.fontColor('#6C63FF').margin({ top: 6 })
Text(label).fontSize(13).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1)
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 4, right: 4 })
.alignItems(HorizontalAlign.Center)
}
// 使用
Row() {
this.overviewBox('📝', '总日记', this.totalCount.toString())
this.overviewBox('📅', '本月', this.thisMonthCount.toString())
}
.width('94%')
三、Page 生命周期最佳实践
3.1 数据同步机制
// 每个页面都实现的模式
aboutToAppear(): void {
this.loadData(); // 首次加载
}
onPageShow(): void {
this.loadData(); // 每次显示时刷新
}
3.2 为什么 onPageShow 必不可少?
| 场景 | 触发时机 | 是否需要刷新 |
|---|---|---|
| 首次打开应用 | aboutToAppear | ✅ |
| 从写日记保存返回 | onPageShow | ✅(新增数据) |
| 从日历删除后返回 | onPageShow | ✅(已删除数据) |
| 从后台切回前台 | onPageShow | ✅(可能被清理) |
| 页面重建(内存回收) | aboutToAppear | ✅ |
四、性能优化提示
4.1 ForEach 的 key 使用
// ✅ 正确:使用稳定唯一标识
ForEach(this.entries, (item) => { /* ... */ }, (item) => item.id)
// ❌ 错误:使用随机数
ForEach(this.entries, (item) => { /* ... */ }, (item) => Math.random())
4.2 避免不必要的计算
// 每次 buildCalendar 都重新计算全部日期
// 小数据量下没问题,100+条目时建议用 Map 优化查找
// 优化思路:建立 date → DiaryEntry 的 Map
buildCalendarWithMap(): void {
let entryMap: Map<string, DiaryEntry> = new Map();
for (let entry of this.entries) {
entryMap.set(entry.date, entry);
}
// 然后直接 entryMap.get(dateStr) 即可
}
五、踩坑记录
🕳️ 坑1:getDay() 返回值
问题:new Date().getDay() 返回 0 表示周日,但中国人的习惯是周一为一周第一天(1),周日为最后一天(7)。直接使用会导致日历网格错位。
解决:
// getDay() 返回 0=周日,1=周一,...,6=周六
// 使用 || 7 把 0 转为 7,得到 1=周一,...,7=周日
let startWeekday = firstDay.getDay() || 7;
// 补位循环从 1 到 startWeekday-1
for (let i = 1; i < startWeekday; i++) {
days.push({ day: 0, isEmpty: true, ... });
}
图解:
假设4月1号是周二 → getDay()=2 → 周一补1个空位
假设4月1号是周日 → getDay()=0 → ||7 → 7 → 补6个空位(周一到周六)
🕳️ 坑2:ForEach key 生成策略
问题:CalendarPage 中的空位日期(cd.isEmpty === true)没有 dateStr,直接用空字符串作为 key 会导致 ForEach 发出 key 重复的警告。
解决:空位使用 Math.random().toString() 作为唯一 key:
// CalendarDay 接口中 dateStr 可能为空字符串
// 空位已用 isEmpty=true 标记,dateStr=""
// 所以 key 函数要兜底处理
(cd: CalendarDay) => cd.dateStr ? cd.dateStr : Math.random().toString()
🕳️ 坑3:Grid 容器高度估算
问题:Grid 容器在内容未完全渲染时高度计算可能不准确,导致 Scroll 滚动区域过小或过大。
解决:
- 给 Grid 设置固定宽度(如
width('94%')) - Grid 内容由
rowsGap和 GridItem 高度自动撑开 - 不要在 Grid 外套一个固定高度的容器
🕳️ 坑4:柱状图宽度百分比拼接
问题:ArkTS 中给 width 赋值百分比时,必须带 '%' 后缀的字符串,而不是 0-1 的小数。
// ✅ 正确:字符串拼接 %
.width((ms.count / this.maxCount * 100) + '%')
// ❌ 错误:直接传小数
.width(ms.count / this.maxCount)
🕳️ 坑5:AppStorage 数据同步遗漏
问题:在 CalendarPage 中删除日记后,只更新了本地 this.entries,但没有同步到 AppStorage,导致返回首页后数据还在。
解决:三件事必须全部执行:
deleteEntry(id: string): void {
// 1. 更新本地状态
let newList = this.entries.filter(e => e.id !== id);
this.entries = newList;
// 2. 同步到全局存储(绝对不能忘!)
AppStorage.set<DiaryEntry[]>('entries', newList);
// 3. 清空选中状态
this.selectedEntry = undefined;
this.selectedDate = '';
// 4. 重新构建日历
this.buildCalendar();
}
技巧:可以用
Array.filter()替代手写循环,更简洁:let newList = this.entries.filter(e => e.id !== id);
六、总结与下篇预告
本篇我们完成了「心情日记」中交互最复杂的两个页面:
日历页面关键技术点
| 功能模块 | 核心技术 | 难度 |
|---|---|---|
| 月历生成 | Date API + 补位算法 | ⭐⭐⭐ |
| 月份切换 | 边界条件处理(跨年) | ⭐⭐ |
| Grid 网格渲染 | columnsTemplate 7列 + 条件渲染 | ⭐⭐⭐ |
| 日期选中 | 比较 dateStr 精确匹配 | ⭐ |
| 日记详情 | 条件渲染 + 标签解析 | ⭐⭐ |
| 删除功能 | AppStorage 同步 + 重建日历 | ⭐⭐ |
统计页面关键技术点
| 功能模块 | 核心技术 | 难度 |
|---|---|---|
| 概览卡片 | @Builder 复用 | ⭐ |
| 本月统计 | 字符串前缀匹配 indexOf === 0 |
⭐⭐ |
| 心情分布 | 计数组装 + 排序 | ⭐⭐ |
| 水平柱状图 | 百分比宽度 + 颜色映射 | ⭐⭐⭐ |
| 7天趋势 | 日期回推 + 数据查表 | ⭐⭐ |
最后一篇将是系列终章,完成个人中心页面功能,并进行全项目技术回顾和编码规范总结,包括:
- 标签云实现
- @Builder 三种模式总结
- AppStorage vs PersistentStorage vs RDB 持久化方案对比
- 连续签到算法优化
- 完整踩坑记录合集
敬请期待最终篇!
如有疑问,欢迎在评论区留言交流!
更多推荐

所有评论(0)