鸿蒙原生应用实战(四):交互进阶 — 日历视图与数据统计页面

本文是系列第四篇,深入「心情日记」中两个交互最复杂的页面:日历视图(CalendarPage)和数据统计(StatsPage)。将讲解月历算法、Grid 网格布局、柱状图实现、7日心情趋势等核心功能。


一、日历视图(CalendarPage.ets)全面拆解

日历页面是本应用交互最复杂的页面,它需要:

  1. 显示指定月份的完整日历网格
  2. 在有日记的日期上显示心情 Emoji
  3. 支持月份切换(上/下月)
  4. 点击日期查看当日日记详情
  5. 支持删除日记

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(); // 重新构建日历
}

三件事别忘了

  1. 更新 AppStorage
  2. 清空选中状态
  3. 重新构建日历网格

二、数据统计页面(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 持久化方案对比
  • 连续签到算法优化
  • 完整踩坑记录合集

敬请期待最终篇!
在这里插入图片描述


如有疑问,欢迎在评论区留言交流!

Logo

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

更多推荐