HarmonyOS知识库——标签筛选页的双状态切换与Flex布局
知识库 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 篇笔记的规模下,重新统计的性能开销可以忽略。
更多推荐

所有评论(0)