鸿蒙原生应用实战(四):交互进阶 — 分类筛选列表与详情页倒计时
·
鸿蒙原生应用实战(四):交互进阶 — 分类筛选列表与详情页倒计时
本文是系列第四篇,深入「纪念日管家」两个交互最丰富的页面:全部列表页(ListPage)和详情页(DetailPage)。你将学到分类标签筛选、swipeAction 滑动删除、倒计时大数字展示、备注编辑等进阶技术。
一、全部列表页(ListPage.ets)全面拆解
1.1 页面功能
┌──────────────────────────────────────────┐
│ < 返回 全部纪念日 │
├──────────────────────────────────────────┤
│ [全部] [🎂生日] [💍纪念日] [🎉节日] │ ← 分类标签(可滚动)
│ [✈️旅行] [💼工作] [🏥健康] [📌其他] │
├──────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │
│ │ 🎂 女朋友生日 ← 滑动删除 │ │
│ │ 03-15 · 第6年 75天 │ │
│ ├──────────────────────────────────┤ │
│ │ 💍 结婚纪念日 │ │ ← 按日期排序
│ │ 05-20 · 第4年 30天 │ │
│ ├──────────────────────────────────┤ │
│ │ ... │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
1.2 状态变量
@State events: AnniversaryEvent[] = []; // 全部事件
@State filteredList: AnniversaryEvent[] = []; // 筛选后事件
@State filterCategory: string = 'all'; // 当前筛选分类
@State categories: EventCategory[] = []; // 所有分类
1.3 分类标签筛选
// 标签筛选行
Row() {
this.catChip('全部', 'all')
ForEach(this.categories, (c: EventCategory) => {
this.catChip(c.icon + c.name, c.id)
}, (c: EventCategory) => c.id)
}.width('100%').padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
// 标签组件
@Builder catChip(label: string, id: string) {
Text(label).fontSize(12)
.fontColor(this.filterCategory === id ? '#FFFFFF' : '#666666')
.backgroundColor(this.filterCategory === id ? '#6C63FF' : '#F0F0F0')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(12).margin({ right: 4 })
.onClick(() => { this.onCategoryClick(id); })
}
标签设计要点:
- 选中态:白字+紫底(#6C63FF)
- 未选态:灰字+浅灰底(#F0F0F0)
- 胶囊圆角(12px)
- 所有标签横向排列,超出可横向滚动
1.4 筛选逻辑
applyFilter(): void {
let result: AnniversaryEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
let e = this.events[i];
let match = true;
// 分类筛选
if (this.filterCategory !== 'all' && e.categoryId !== this.filterCategory) match = false;
if (match) result.push(e);
}
// 按日期排序
result.sort((a, b) => {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
});
this.filteredList = result;
}
onCategoryClick(catId: string): void {
this.filterCategory = catId;
this.applyFilter();
}
筛选流程:
- 用户点击分类标签 →
onCategoryClick(catId) - 设置
filterCategory - 调用
applyFilter()重新筛选 - 遍历所有事件,匹配分类
- 按日期排序后渲染
1.5 事件行
@Builder eventRow(ev: AnniversaryEvent) {
Row() {
// 分类图标
Text(getCategoryById(ev.categoryId).icon).fontSize(22)
.width(40).height(40).backgroundColor('#F5F5F5').borderRadius(20)
// 名称 + 日期/第N年
Column() {
Text(ev.name).fontSize(15).fontWeight(FontWeight.Medium)
Text(ev.date + (ev.startYear > 0 ? ' · ' + getCountdown(ev.date, ev.startYear).age : ''))
.fontSize(12).fontColor('#999999').margin({ top: 1 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
// 倒计时天数
Text(getCountdown(ev.date, ev.startYear).days + '天').fontSize(14)
.fontWeight(FontWeight.Bold).fontColor(getCategoryById(ev.categoryId).color)
}.padding({ left: 14, right: 14, top: 8, bottom: 8 }).height(56)
.onClick(() => {
router.pushUrl({ url: 'pages/DetailPage', params: { eventId: ev.id } });
})
}
1.6 滑动删除(swipeAction)
ListItem() {
this.eventRow(ev)
}
.swipeAction({
end: {
// 滑动结束时显示的按钮
builder: (): void => { this.delBtn(ev.id) },
// 滑动到触发位置时自动执行
onAction: (): void => { this.deleteEvent(ev.id) }
}
})
// 删除按钮
@Builder delBtn(id: string) {
Column() {
Text('删除').fontSize(14).fontColor('#FFFFFF').padding(16)
}.backgroundColor('#FF4757').height('100%').justifyContent(FlexAlign.Center)
}
// 删除逻辑
deleteEvent(id: string): void {
let newList: AnniversaryEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
if (this.events[i].id !== id) newList.push(this.events[i]);
}
this.events = newList;
AppStorage.set<AnniversaryEvent[]>('events', newList);
this.applyFilter(); // 重新筛选
}
swipeAction 配置说明:
.swipeAction({
end: { // ← 从右向左滑动(end 表示滑动结束端)
builder: ..., // 自定义滑动暴露的按钮 UI
onAction: ... // 滑动到位后的回调
}
})
swipeAction vs 手动 Button 删除:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 滑动删除 | 手势自然、节省空间 | 用户可能不知道可滑动 |
| 长按删除 | 操作隐蔽 | 发现率低 |
| 按钮删除 | 明确可见 | 占用列表空间 |
本项目使用滑动删除,适合列表项密集的场景。
1.7 空状态
if (this.filteredList.length === 0) {
Column() {
Text('📅').fontSize(48).margin({ bottom: 8 })
Text('没有找到纪念日').fontSize(16).fontColor('#CCCCCC')
}.width('100%').height(200)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
二、详情页(DetailPage.ets)全面拆解
2.1 页面功能
┌──────────────────────────────────────────┐
│ < 返回 详情 │
├──────────────────────────────────────────┤
│ 🎂 │
│ 女朋友生日 │ ← 标题区
│ 生日 · 3月15日 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 还有 │ │
│ │ 75 │ │ ← 大号倒计时
│ │ 天 │ │
│ │ 第6年 🎉 │ │
│ └──────────────────────────────────┘ │
│ │
│ 📅 日期 3月15日 │
│ 📂 分类 生日 │ ← 信息列表
│ 📆 起始 2020年 │
│ ⏰ 提醒 提前7天 │
│ │
│ 📝 备注 [编辑] │
│ 别忘了买礼物! │ ← 可编辑备注
│ │
│ ┌──────────────────────────────────┐ │
│ │ 删除这个纪念日 │ │ ← 红色边框按钮
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
2.2 状态变量
@State event: AnniversaryEvent = {
id: '', name: '', date: '01-01', startYear: 0,
categoryId: 'other', note: '', reminderDays: 0
};
@State events: AnniversaryEvent[] = [];
@State editNote: string = ''; // 编辑中的备注
@State showNoteInput: boolean = false; // 是否显示备注输入框
2.3 参数接收与数据加载
aboutToAppear(): void {
// 1. 从路由参数获取 eventId
let p = router.getParams() as Record<string, Object>;
let eventId = p['eventId'] as string;
// 2. 读取全局数据
let stored = AppStorage.get<AnniversaryEvent[]>('events');
if (stored) {
this.events = stored;
// 3. 查找匹配的事件
for (let i = 0; i < stored.length; i++) {
if (stored[i].id === eventId) {
this.event = stored[i];
this.editNote = stored[i].note;
break;
}
}
}
}
2.4 大号倒计时展示
Column() {
Text(getCountdown(this.event.date, this.event.startYear).label)
.fontSize(14).fontColor('#999999')
Text(getCountdown(this.event.date, this.event.startYear).days + '天')
.fontSize(48).fontWeight(FontWeight.Bold)
.fontColor('#6C63FF').margin({ top: 4 })
if (getCountdown(this.event.date, this.event.startYear).age) {
Text(getCountdown(this.event.date, this.event.startYear).age)
.fontSize(16).fontColor(getCategoryById(this.event.categoryId).color)
.margin({ top: 4 })
}
}
.padding(20)
.backgroundColor('#FFFFFF').borderRadius(12)
.alignItems(HorizontalAlign.Center)
视觉层级:
"还有" → 14px 灰色(辅助信息)
"75天" → 48px 紫色粗体(核心信息,大号醒目)
"第6年" → 16px 分类主题色(个性化信息)
三个信息从上到下,字号逐级变化,形成清晰的视觉层次。
2.5 信息行
@Builder infoRow(icon: string, label: string, value: string) {
Row() {
Text(icon).fontSize(16).margin({ right: 8 })
Text(label).fontSize(14).fontColor('#999999').width(50)
Text(value).fontSize(15).fontColor('#333333')
Blank()
}.width('100%').padding({ top: 6, bottom: 6 })
}
// 使用
this.infoRow('📅', '日期', '3月15日')
this.infoRow('📂', '分类', '生日')
2.6 备注编辑(原地编辑)
Column() {
Row() {
Text('📝 备注').fontSize(14).fontColor('#999999')
Blank()
Text('编辑').fontSize(13).fontColor('#6C63FF')
.onClick(() => {
this.editNote = this.event.note;
this.showNoteInput = !this.showNoteInput;
})
}.width('100%')
if (this.showNoteInput) {
// 编辑模式
TextInput({ placeholder: '添加备注', text: this.editNote })
.fontSize(15).height(40).width('100%').placeholderColor('#CCCCCC').margin({ top: 6 })
.onChange((v: string) => { this.editNote = v; })
Button('保存').width('100%').height(32).backgroundColor('#6C63FF')
.borderRadius(16).fontSize(13).fontColor('#FFFFFF').margin({ top: 6 })
.onClick(() => { this.saveNote(); })
} else {
// 展示模式
Text(this.event.note ? this.event.note : '暂无备注')
.fontSize(15).fontColor(this.event.note ? '#333333' : '#CCCCCC').width('100%').margin({ top: 6 })
}
}
编辑流程:
- 点击"编辑" → 显示 TextInput
- 修改内容 → 点击"保存"
saveNote()更新 event.note →saveData()同步到 AppStorage- 隐藏输入框,展示新内容
2.7 保存与删除
// 保存数据到 AppStorage
saveData(): void {
for (let i = 0; i < this.events.length; i++) {
if (this.events[i].id === this.event.id) {
this.events[i] = this.event;
break;
}
}
AppStorage.set<AnniversaryEvent[]>('events', this.events);
}
// 保存备注
saveNote(): void {
this.event.note = this.editNote;
this.showNoteInput = false;
this.saveData();
}
// 删除事件
deleteEvent(): void {
let newList: AnniversaryEvent[] = [];
for (let i = 0; i < this.events.length; i++) {
if (this.events[i].id !== this.event.id) newList.push(this.events[i]);
}
AppStorage.set<AnniversaryEvent[]>('events', newList);
router.back();
}
三、踩坑记录
🕳️ 坑1:swipeAction 的 onAction 会触发两次
问题:onAction 在滑动触发后还会在点击 builder 按钮时再次触发。
解决:builder 只负责显示,删除逻辑只放在 onAction 或 builder 的点击事件中,不要重复。
🕳️ 坑2:详情页返回后列表未刷新
问题:在 DetailPage 编辑备注或删除后返回 ListPage,列表数据未更新。
解决:ListPage 实现 onPageShow 重新加载数据:
onPageShow(): void { this.loadData(); }
🕳️ 坑3:备注编辑时路由参数丢失
问题:DetailPage 的 eventId 只在 aboutToAppear 时获取一次,如果页面重建需要重新获取。
解决:将 eventId 存储在组件变量中,在 saveData 时完整遍历 events 数组匹配。

如有疑问,欢迎留言交流!
更多推荐

所有评论(0)