鸿蒙原生应用实战(三):UI构建 — 首页纪念日卡片与添加事件页面
·
鸿蒙原生应用实战(三):首页纪念日卡片与添加事件页面
系列回顾:
(一)项目搭建与导航框架
(二)数据模型与状态管理
(三)UI 构建 — 首页纪念日卡片与添加事件页面 ← 本文
一、引言
在前两篇中,我们完成了项目脚手架搭建、路由配置和全局数据模型设计。从本篇开始,真正进入界面开发。
本文将聚焦「纪念日管家」两个核心页面:
| 页面 | 功能 | 难度 |
|---|---|---|
| 首页(Index) | 今日纪念日卡片 + 即将到来列表 | ⭐⭐ |
| AddEvent | 表单录入 + 分类选择 + 校验保存 | ⭐⭐⭐ |
你将掌握:条件渲染控制空状态/有数据状态、ForEach 列表渲染与排序、Grid 分类选择器、多层表单校验体系。
二、首页(Index.ets)全面拆解
2.1 整体布局
┌──────────────────────────────────────────┐
│ 纪念日管家 │
├──────────────────────────────────────────┤
│ ┌────────────────────────────────────┐ │
│ │ 6月8日 2个纪念日 │ │
│ │ │ │
│ │ 🎂 女朋友生日 第6年 │ │ ← 今日纪念日卡片
│ │ 💍 结婚纪念日 第4年 │ │
│ └────────────────────────────────────┘ │
│ │
│ ➕添加 全部 日历 我的 │ ← 快捷导航
│ │
│ 即将到来(30天内) 全部 > │
│ ┌──────────────────────────────────┐ │
│ │ 🏥 体检 98天 │ │
│ │ 还有98天 │ │
│ ├──────────────────────────────────┤ │
│ │ 🎂 老爸生日 61天 │ │ ← 倒计时列表
│ │ 第71年 还有61天 │ │
│ ├──────────────────────────────────┤ │
│ │ 🌕 中秋节 120天 │ │
│ │ 还有120天 │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
2.2 状态变量设计
@State events: AnniversaryEvent[] = [];
@State todayEvents: AnniversaryEvent[] = []; // 今日事件
@State upcomingEvents: AnniversaryEvent[] = []; // 30天内即将到来
@State todayStr: string = ''; // 今天的 MM-DD 格式
📌 ArkTS 严格模式注意:所有
@State变量必须显式类型声明,不可写@State events = [](会被 lint 报arkts-no-noninferrable-arr-literals错误)。
2.3 数据加载与列表计算
aboutToAppear(): void {
this.loadData();
}
loadData(): void {
this.todayStr = getToday(); // 工具函数,返回 'MM-DD'
let stored = AppStorage.get<AnniversaryEvent[]>('events');
if (stored) {
this.events = stored;
} else {
// 首次运行:加载示例数据
let samples = getSampleEvents();
this.events = samples;
AppStorage.set<AnniversaryEvent[]>('events', samples);
}
this.calcLists();
}
calcLists(): void {
let today: AnniversaryEvent[] = [];
let upcoming: AnniversaryEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
let e = this.events[i];
let days = getCountdown(e.date, e.startYear).days;
if (e.date === getToday()) {
today.push(e); // ▸ 今天的:放入 today 列表
} else if (days > 0 && days <= 30) {
upcoming.push(e); // ▸ 30天内的:放入 upcoming 列表
}
// days === 0 已在 today 中;days < 0 走明年逻辑不显示
}
// 按剩余天数升序排列(最近的最靠前)
upcoming.sort((a, b) => {
let da = getCountdown(a.date, a.startYear).days;
let db = getCountdown(b.date, b.startYear).days;
return da - db; // 简洁写法
});
this.todayEvents = today;
this.upcomingEvents = upcoming;
}
📐 筛选逻辑说明:
| 条件 | 归属 | 说明 |
|---|---|---|
e.date === getToday() |
todayEvents |
日期完全匹配今天 |
0 < days <= 30 |
upcomingEvents |
未来30天内的事件 |
days === 0 |
已在today | 今天已在上层处理 |
days < 0 |
不显示 | 已过日期走「明年同一天」逻辑 |
2.4 今日纪念日卡片(含空状态)
Column() {
// ── 头部:日期 + 事件数徽章 ──
Row() {
Text(' ' + this.todayStr.slice(0, 2) + '月' + this.todayStr.slice(3) + '日')
.fontSize(15).fontColor('rgba(255,255,255,0.85)')
Blank()
if (this.todayEvents.length > 0) {
Text(this.todayEvents.length + '个纪念日')
.fontSize(13).fontColor('rgba(255,255,255,0.8)')
.backgroundColor('rgba(255,255,255,0.2)').borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}.width('100%')
// ── 主体:有事件 / 无事件 两套 UI ──
if (this.todayEvents.length === 0) {
// 空状态
Text('今天没有特殊日子').fontSize(18).fontColor('#FFFFFF')
.margin({ top: 16 })
Text('去添加一个纪念日吧!').fontSize(13).fontColor('rgba(255,255,255,0.7)')
.margin({ top: 6 })
} else {
// 遍历展示今日事件
ForEach(this.todayEvents, (ev: AnniversaryEvent) => {
Row() {
Text(getCategoryById(ev.categoryId).icon).fontSize(28)
.margin({ right: 8 })
Column() {
Text(ev.name).fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
if (ev.startYear > 0) {
let years = new Date().getFullYear() - ev.startYear;
Text('第' + years + '年')
.fontSize(13).fontColor('rgba(255,255,255,0.7)')
}
}.alignItems(HorizontalAlign.Start)
Blank()
}.width('100%').margin({ top: 8 })
}, (ev: AnniversaryEvent) => ev.id)
}
}
.padding(20)
.backgroundColor('#6C63FF')
.borderRadius(16)
🎯 设计要点:
| 技巧 | 实现方式 | 效果 |
|---|---|---|
| 半透明文字 | rgba(255,255,255,0.85) |
深紫背景上层次分明 |
| 条件渲染 | if (todayEvents.length === 0) |
空状态 vs 有数据两套UI |
| 日期格式 | slice(0,2) + '月' + slice(3) + '日' |
06-08 → 6月8日 |
| 徽章 | 半透明白色圆角背景 | 视觉轻量不抢眼 |
| 年数计算 | new Date().getFullYear() - startYear |
动态计算,无需存储 |
2.5 即将到来列表
@Builder eventCard(ev: AnniversaryEvent) {
Row() {
// ── 左:分类图标 ──
Text(getCategoryById(ev.categoryId).icon).fontSize(24)
.width(40).height(40).textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5').borderRadius(20)
// ── 中:名称 + 年数 + 倒计时标签 ──
Column() {
Text(ev.name).fontSize(15).fontWeight(FontWeight.Medium)
Row() {
let countdown = getCountdown(ev.date, ev.startYear);
if (countdown.age) {
Text(countdown.age)
.fontSize(11).fontColor(getCategoryById(ev.categoryId).color)
}
Text(countdown.label + countdown.days + '天')
.fontSize(12).fontColor('#999999').margin({ left: 6 })
}.width('100%').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
// ── 右:大号天数 ──
Text(getCountdown(ev.date, ev.startYear).days + '天').fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(getCategoryById(ev.categoryId).color)
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 }).height(64)
.onClick(() => {
router.pushUrl({
url: 'pages/DetailPage',
params: { eventId: ev.id }
});
})
}
每行三栏布局:
┌──────────┬───────────────────────────┬──────────┐
│ 图标 │ 事件名 + 年数 + 倒计时 │ 98天 │
│ (圆底) │ (左对齐, 自动填充) │ (主题色) │
└──────────┴───────────────────────────┴──────────┘
📌
router导入:API 23 下需使用import router from '@ohos.router',不可用@kit.AbilityKit。
三、添加纪念日页面(AddEvent.ets)全面拆解
3.1 页面布局
┌──────────────────────────────────────────┐
│ < 返回 添加纪念日 │
├──────────────────────────────────────────┤
│ 名称 * │
│ ┌──────────────────────────────────┐ │
│ │ 如: 妈妈生日 │ │
│ └──────────────────────────────────┘ │
│ │
│ 月份 * 日期 * │ ← 并行输入
│ ┌─────┐ ┌─────┐ │
│ │ 01 │ │ 29 │ │
│ └─────┘ └─────┘ │
│ │
│ 起始年份(可选) │
│ ┌──────────────────────────────────┐ │
│ │ 如: 2020 │ │
│ └──────────────────────────────────┘ │
│ │
│ 分类 │
│ 🎂生日 💍纪念日 🎉节日 ✈️旅行 │ ← 4列 Grid
│ 💼工作 ❤️健康 🫎其他 │
│ │
│ 提前提醒 │
│ ┌──────┐ 天 │
│ │ 7 │ │
│ └──────┘ │
│ │
│ 备注 │
│ ┌──────────────────────────────────┐ │
│ │ 可选 │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 保存 │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
3.2 状态变量
@State name: string = '';
@State month: string = '';
@State day: string = '';
@State startYear: string = '';
@State selectedCategory: string = 'birthday';
@State note: string = '';
@State reminder: string = '7';
@State categories: EventCategory[] = [];
3.3 日期输入:为什么不用 DatePicker?
这是一个典型的设计决策。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 双 TextInput | 自由输入、格式可控 MM-DD | 需手动校验 | ✅ 仅需月日的纪念日 |
| DatePicker | 原生选择、不会输错 | 含年月日,选择步骤多 | 需要完整日期的场景 |
纪念日本质上只需要 月+日(年份仅用于计算「第N年」),用两个 TextInput 比 DatePicker 更直接。
Row() {
// 月份输入
Column() {
Text('月份 *').fontSize(13).fontColor('#999999').width('100%')
TextInput({ placeholder: '01', text: this.month })
.fontSize(20).height(44).width('100%').type(InputType.Number)
.placeholderColor('#CCCCCC').margin({ top: 4 })
.onChange((v: string) => { this.month = v; })
}.layoutWeight(1).margin({ right: 8 })
// 日期输入
Column() {
Text('日期 *').fontSize(13).fontColor('#999999').width('100%')
TextInput({ placeholder: '01', text: this.day })
.fontSize(20).height(44).width('100%').type(InputType.Number)
.placeholderColor('#CCCCCC').margin({ top: 4 })
.onChange((v: string) => { this.day = v; })
}.layoutWeight(1)
}.width('90%')
3.4 分类选择器(Grid)
7 个分类,用 4 列 Grid 排成两行,选中的高亮显示:
Grid() {
ForEach(this.categories, (c: EventCategory) => {
GridItem() {
Column() {
Text(c.icon).fontSize(24)
Text(c.name).fontSize(11)
.fontColor(this.selectedCategory === c.id ? '#6C63FF' : '#666666')
.margin({ top: 2 })
}.width('100%').padding({ top: 8, bottom: 8 })
.backgroundColor(this.selectedCategory === c.id ? '#EEEAFF' : '#F5F5F5')
.borderRadius(10).alignItems(HorizontalAlign.Center)
}.onClick(() => { this.selectedCategory = c.id; })
}, (c: EventCategory) => c.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4列等宽
.columnsGap(8).rowsGap(8).width('90%')
选中态变化:
- 背景色:
#F5F5F5(灰)→#EEEAFF(浅紫) - 文字色:
#666666→#6C63FF(主题紫)
3.5 保存逻辑:多层校验体系
saveEvent(): void {
// 1️⃣ 非空校验
if (this.name.trim() === '' || this.month.trim() === '' || this.day.trim() === '') {
// 可在此弹出 Toast 提示
return;
}
// 2️⃣ 数值范围校验
let mm = Number.parseInt(this.month);
let dd = Number.parseInt(this.day);
if (isNaN(mm) || mm < 1 || mm > 12) return;
if (isNaN(dd) || dd < 1 || dd > 31) return;
// 3️⃣ 可选字段处理(空字符串转默认值)
let sy = Number.parseInt(this.startYear);
if (isNaN(sy)) sy = 0;
let rd = Number.parseInt(this.reminder);
if (isNaN(rd) || rd < 0) rd = 0;
// 4️⃣ 构造事件对象
let ev: AnniversaryEvent = {
id: generateId(),
name: this.name.trim(),
date: mm.toString().padStart(2, '0') + '-' + dd.toString().padStart(2, '0'),
startYear: sy,
categoryId: this.selectedCategory,
note: this.note.trim(),
reminderDays: rd
};
// 5️⃣ 保存到 AppStorage 并返回
let stored = AppStorage.get<AnniversaryEvent[]>('events');
let list: AnniversaryEvent[] = stored ? stored : [];
list.push(ev);
AppStorage.set<AnniversaryEvent[]>('events', list);
router.back();
}
校验体系一览:
| 层级 | 检查项 | 工具 |
|---|---|---|
| 1️⃣ 空值 | name / month / day 非空 | trim() === '' |
| 2️⃣ 类型 | 数字型字段 | Number.parseInt + isNaN |
| 3️⃣ 范围 | 月 1-12,日 1-31 | 不等式判断 |
| 4️⃣ 格式化 | MM-DD 统一两位 | padStart(2, '0') |
| 5️⃣ 默认值 | 年份 / 提醒天数 | 空值回退 sy = 0 |
四、通用数据模型与工具函数
两个页面依赖同一套数据模型和工具函数,定义在 model/AnniversaryData.ets 中:
// ── 事件分类 ──
interface EventCategory {
id: string;
name: string;
icon: string;
color: string;
}
// ── 纪念日事件 ──
interface AnniversaryEvent {
id: string;
name: string;
date: string; // 'MM-DD' 格式
startYear: number; // 0 表示不计算年数
categoryId: string;
note: string;
reminderDays: number;
}
// ── 示例数据(首次运行填充) ──
function getSampleEvents(): AnniversaryEvent[] {
return [
{ id: '1', name: '女朋友生日', date: '06-08', startYear: 2019,
categoryId: 'birthday', note: '', reminderDays: 7 },
{ id: '2', name: '结婚纪念日', date: '06-08', startYear: 2021,
categoryId: 'anniversary', note: '', reminderDays: 7 },
{ id: '3', name: '体检', date: '09-15', startYear: 0,
categoryId: 'health', note: '记得空腹', reminderDays: 3 },
// ...
];
}
// ── 倒计时计算 ──
function getCountdown(date: string, startYear: number): CountdownResult {
let now = new Date();
let [m, d] = date.split('-').map(Number);
// 今年的目标日期
let target = new Date(now.getFullYear(), m - 1, d);
let diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let label = '还有';
if (diff < 0) {
// 已过 → 算明年的
target = new Date(now.getFullYear() + 1, m - 1, d);
diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
label = '还有';
} else if (diff === 0) {
label = '就是今天!';
}
let age = startYear > 0 ? '第' + (target.getFullYear() - startYear) + '年' : '';
return { days: diff, label: label, age: age };
}
五、踩坑记录
🕳️ 坑1:@State 数组字面量必须显式类型
// ❌ 编译报 arkts-no-noninferrable-arr-literals
@State todayEvents = [];
// ✅ 必须显式声明类型
@State todayEvents: AnniversaryEvent[] = [];
🕳️ 坑2:ForEach 的 keyGenerator 参数不能省略
ForEach(this.todayEvents, (ev) => { ... })
// 需要第三个参数作为 key
ForEach(this.todayEvents, (ev: AnniversaryEvent) => { ... }, (ev: AnniversaryEvent) => ev.id)
🕳️ 坑3:router 导入方式
API 23 下必须用旧式导入:
import router from '@ohos.router'; // ✅ 正确
// import { router } from '@kit.AbilityKit'; // ❌ API 23 不导出
🕳️ 坑4:TextInput 的 type 参数
// 数字键盘
TextInput({ placeholder: '01' }).type(InputType.Number)
// 别忘了在构造参数里传 text,否则无法双向绑定
TextInput({ placeholder: '01', text: this.month })
六、下篇预告
本篇完成了首页和添加纪念日页面的开发。下一篇将是系列中最复杂的部分:
| 页面 | 核心挑战 |
|---|---|
| 全部列表页(EventList) | 分类筛选标签栏 + swipeAction 滑动删除 + 空状态 |
| 详情页(DetailPage) | 大号倒计时动画 + 第N年展示 + router.getParams 参数接收 |
你将学到:
Swiper+swipeAction滑动删除的最佳实践- 多标签筛选的
@State+computed模式 router.getParams()的类型安全写法(as Record<string, Object>)
敬请期待!

如果本文对你有帮助,欢迎点赞收藏。有任何 UI 设计建议,评论区见!
更多推荐

所有评论(0)