鸿蒙原生应用实战(四):交互进阶 — 分类筛选列表与详情页倒计时

本文是系列第四篇,深入「纪念日管家」两个交互最丰富的页面:全部列表页(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();
}

筛选流程

  1. 用户点击分类标签 → onCategoryClick(catId)
  2. 设置 filterCategory
  3. 调用 applyFilter() 重新筛选
  4. 遍历所有事件,匹配分类
  5. 按日期排序后渲染

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 })
  }
}

编辑流程

  1. 点击"编辑" → 显示 TextInput
  2. 修改内容 → 点击"保存"
  3. saveNote() 更新 event.note → saveData() 同步到 AppStorage
  4. 隐藏输入框,展示新内容

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 数组匹配。

在这里插入图片描述


如有疑问,欢迎留言交流!

Logo

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

更多推荐