知识库 App 的 TagFilterPage 有两种完全不同的 UI 状态——没有选中标签时显示标签网格,选中后切换为笔记列表。这种"同一页面两种视图"的设计让用户不需要跳转就能在"浏览标签"和"查看笔记"之间切换,减少了页面跳转的开销。

完整效果
在这里插入图片描述

一、三种入口的不同状态

入口 selectedTag 初始值 页面初始状态
首页笔记标签点击 有值(如"HarmonyOS") 直接显示筛选结果
ProfilePage 标签云点击 有值(如"编程") 直接显示筛选结果
直接打开 TagFilterPage 空字符串 显示标签网格

有入口传了标签名——页面跳过标签网格,直接显示该标签的笔记。 没有传标签名——先展示所有标签供用户选择。两种入口共享同一个页面,靠 selectedTag 是否有值来区分状态。

二、aboutToAppear 的初始化逻辑

@State tags: TagCount[] = []
@State selectedTag: string = ''
@State notes: Note[] = []

aboutToAppear(): void {
  this.tags = getAllTags()
  const p = router.getParams() as Record<string, Object>
  if (p && p['tag']) {
    this.selectedTag = p['tag'] as string
    this.filterNotes()
  }
}

在这里插入图片描述

三个 @State 的初始化顺序: 先加载所有标签(getAllTags()),再判断有没有传标签名,有就设置 selectedTag 并调用 filterNotes。

为什么 tags 无论有没有传标签名都要加载? 因为标签网格和筛选列表都需要 tags 数据——标签网格直接展示,筛选列表的导航栏可能需要显示标签信息。提前加载避免后续使用时数据为空。

踩坑记录: 最初 this.tags = getAllTags() 只在没有传标签名时才执行,传了标签名时跳过了。结果筛选列表的导航栏想显示"所有标签"数量时数据为空。改成无条件加载后解决。

三、filterNotes 的双重遍历

private filterNotes(): void {
  if (this.selectedTag.length === 0) { this.notes = []; return }
  const all: Note[] = getNotes()
  const r: Note[] = []
  for (let i: number = 0; i < all.length; i++) {
    for (let j: number = 0; j < all[i].tags.length; j++) {
      if (all[i].tags[j] === this.selectedTag) { r.push(all[i]); break }
    }
  }
  this.notes = r
}

在这里插入图片描述

外层遍历笔记,内层遍历每篇笔记的标签。 找到匹配的标签后立即 break 跳出内层循环——一篇笔记只需要匹配一次,不需要检查所有标签。

selectedTag 为空时返回空数组。 这个边界检查防止了"没有选中标签时显示全部笔记"的错误行为。

和 getRecipesByCat 的对比: 两者都是"遍历数据 + 条件过滤"的模式。区别是 getRecipesByCat 用单层遍历(category 是字符串直接比较),filterNotes 用双重遍历(tags 是数组需要逐个比较)。双重遍历的时间复杂度 O(n×m),n 是笔记数,m 是每篇笔记的标签数。

四、双状态的条件渲染

if (this.selectedTag.length === 0) {
  // 状态一:标签网格
  Scroll() {
    Column() {
      Text('所有标签 (' + this.tags.length + ')').fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor(T1).width('100%').padding({ left: 16 }).margin({ bottom: 8 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.tags, (t: TagCount) => {
          Column() {
            Text('#' + t.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(A).margin({ bottom: 2 })
            Text(t.count + ' 篇').fontSize(11).fontColor(T3)
          }.padding(14).backgroundColor('#FFFFFF').borderRadius(14).margin({ right: 8, bottom: 8 })
          .onClick(() => { this.selectedTag = t.name; this.filterNotes() })
        })
      }.width('100%').padding({ left: 16, right: 16 })
    }
  }
} else {
  // 状态二:筛选笔记列表
  Scroll() {
    Column({ space: 8 }) {
      ForEach(this.notes, (n: Note) => {
        Row() { /* 笔记卡片 */ }
      })
      Blank().height(20)
    }
  }
}

在这里插入图片描述

if-else 切换两种完全不同的 UI 结构——不是隐藏/显示,而是条件渲染。 选中标签前渲染标签网格,选中后渲染笔记列表。两种结构差异太大(Flex 布局 vs Column 列表),无法用"隐藏/显示"实现。

状态切换的触发

// 标签网格里点击某个标签
.onClick(() => { this.selectedTag = t.name; this.filterNotes() })

点击标签时同时做两件事:设置 selectedTag 为标签名,调用 filterNotes 查询匹配笔记。selectedTag 变化后,if-else 判断条件改变,UI 自动切换到筛选列表状态。

状态重置

用户点击返回按钮回到标签网格时,需要重置 selectedTag。当前实现是 router.back() 返回上一页,而不是在当前页面重置。这是路由式导航的标准做法——返回上一页比在当前页面重置更符合用户预期。

五、Flex 布局的标签网格

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(this.tags, (t: TagCount) => {
    Column() {
      Text('#' + t.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(A).margin({ bottom: 2 })
      Text(t.count + ' 篇').fontSize(11).fontColor(T3)
    }.padding(14).backgroundColor('#FFFFFF').borderRadius(14).margin({ right: 8, bottom: 8 })
  })
}

为什么用 Flex 而不是 Row

Row 不支持自动换行——所有标签挤在一行,超出屏幕的部分被截断。 Flex 配合 FlexWrap.Wrap 实现自动换行——放不下的标签自动移到下一行。

Flex + FlexWrap.Wrap 是 ArkTS 里实现"流式布局"的标准方式。 和 CSS 的 flex-wrap: wrap 效果一样。每个标签是一个 Flex 子项,宽度由内容决定,放不换行。

每个标签的结构

Column(标签卡片)
  ├→ Text('#' + t.name)    标签名(紫色粗体)
  └→ Text(t.count + ' 篇')  笔记数量(灰色小字)

两行文字的标签卡片——比单行文字信息量更大。 用户不仅能看到标签名,还能看到该标签下有多少篇笔记。数量信息帮助用户判断"这个标签值得点进去看吗"。

margin({ right: 8, bottom: 8 }) 保证标签之间有间距。 右间距让同一行的标签不会紧贴,底部间距让换行后的标签不会紧贴上一行。

标签网格 vs 首页笔记本卡片

维度 标签网格(Flex) 笔记本卡片(Row)
布局方式 Flex + FlexWrap.Wrap 自动换行 Row 固定一行
子项宽度 内容决定 layoutWeight(1) 均分
子项数量 不确定(当前 12 个标签) 固定(4 个笔记本)
滚动方向 垂直滚动(外层 Scroll) 水平不动(一屏放下)

Flex 适合数量不确定的场景,Row 适合数量固定的场景。 标签可能增加到 20+ 个,需要 Flex 自动换行。笔记本固定 4 个,Row 一屏能放下。

六、导航栏的动态标题

Text(this.selectedTag.length > 0 ? '#' + this.selectedTag : '标签')
  .fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1)
  .margin({ left: 10 }).layoutWeight(1)
if (this.selectedTag.length > 0) Text(this.notes.length + '篇').fontSize(12).fontColor(T3)

在这里插入图片描述

选中标签前标题是"标签",选中后是"#标签名"。 # 前缀让用户知道当前在看哪个标签的筛选结果。

数量显示也根据状态切换。 选中标签后显示"3篇",没选中时不显示——因为标签网格不需要显示笔记数量。

七、标签名的点击跳转链路

整个 App 里有三个地方可以触发标签筛选:

首页笔记卡片 → 点击 #标签 → TagFilterPage(selectedTag=标签名)
  → 点击标签网格某标签 → 同页面切换到筛选列表

ProfilePage 标签云 → 点击 #标签 → TagFilterPage(selectedTag=标签名)
  → 直接显示筛选列表

三种入口最终都汇聚到 TagFilterPage。 这是"统一入口"的设计模式——标签筛选只有一个实现页面,不同入口只是传不同的参数。

和 NoteList 的"万能列表页"模式一致。 NoteList 承载三种数据来源,TagFilterPage 承载两种入口状态。两个页面都是"一个页面多种用途"的设计。

八、整体嵌套结构

Column                                    页面容器
  ├→ Row (导航栏)                          返回 + 动态标题 + 数量
  └→ if (selectedTag === '')              状态判断
       ├→ 状态一:标签网格
       │    └→ Scroll
       │         └→ Column
       │              ├→ Text (标题)
       │              └→ Flex(FlexWrap.Wrap) + ForEach  标签网格
       └→ 状态二:筛选列表
            └→ Scroll
                 └→ Column({ space: 8 })
                      ├→ ForEach(this.notes, ...)      笔记卡片
                      └→ Blank(20)

两种状态共享同一个导航栏——导航栏在 if-else 外面。 无论显示哪种状态,导航栏始终存在。只有下方的内容区域根据状态切换。这种"共享导航栏、切换内容区"的模式避免了导航栏的重复代码。

九、getAllTags 的标签统计逻辑

export function getAllTags(): TagCount[] {
  const result: TagCount[] = []
  for (let i: number = 0; i < _notes.length; i++) {
    for (let j: number = 0; j < _notes[i].tags.length; j++) {
      const t: string = _notes[i].tags[j]
      let found: boolean = false
      for (let k: number = 0; k < result.length; k++) {
        if (result[k].name === t) { result[k].count++; found = true; break }
      }
      if (!found) { result.push({ name: t, count: 1 }) }
    }
  }
  return result
}

三重循环的执行流程:

循环 遍历对象 作用
外层 i _notes(12 篇笔记) 逐篇检查
中层 j _notes[i].tags(每篇的标签数组) 逐个标签检查
内层 k result(已统计的标签) 检查是否已存在

"已存在则 count+1,不存在则新增"的统计模式。 这是"频率统计"的标准实现——遍历数据,用结果数组记录每个元素出现的次数。

12 篇笔记、每篇 2-3 个标签、约 15 个不重复标签——三重循环的总执行次数约 12×3×15=540 次。 在这个数据规模下性能完全不是问题。如果标签数量增大到上千,可以用 Map 优化到 O(n×m)。

十、标签统计的实时性

aboutToAppear(): void { this.tags = getAllTags() }
onPageShow(): void { this.tags = getAllTags() }

每次页面显示都重新调用 getAllTags()。 用户在 NoteEditor 新增了笔记(带新标签),返回 TagFilterPage 后标签网格自动更新——新标签出现,旧标签的 count 增加。

为什么不用缓存? 因为标签数据来自 _notes 数组,每次 addNote/deleteNote 都会修改 _notes。重新调用 getAllTags() 能保证拿到最新数据。在 12 篇笔记的规模下,重新统计的性能开销可以忽略。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐