HarmonyOS时光胶囊——时间线的搜索、视图切换与空状态设计
时间线 Tab 从"纯列表+筛选"升级成了"搜索+双视图+五维筛选+空状态引导"的完整信息架构。新增的搜索框支持关键词实时过滤,视图切换让卡片在列表和网格间自由切换,收藏筛选独立于原有四种状态筛选,空状态从"没有数据"变成了引导用户创建第一个胶囊。这些改动让时间线从"能用"变成了"好用"。
完整效果
一、时间线的区块演进
| 版本 | 区块 | 筛选 | 视图 |
|---|---|---|---|
| 基础版 | 标题 + 筛选 + 列表 | 4 种 | 仅列表 |
| 优化版 | 标题 + 搜索 + 筛选 + 内容区 | 5 种 | 列表/网格 |
新增了三个能力:搜索框、第五种筛选(收藏)、列表/网格双视图。
二、搜索框的实现
搜索 UI 结构
Row() {
Text('🔍').fontSize(FontSize.md).margin({ right: Spacing.xs })
TextInput({ placeholder: '搜索...' })
.layoutWeight(1).height(36).fontSize(FontSize.sm)
.fontColor(this.getColors().text)
.placeholderColor(this.getColors().textSecondary)
.backgroundColor('transparent')
.onChange((value: string) => this.searchKeyword = value)
// 清除按钮
if (this.searchKeyword || this.activeTag) {
Text('✕').fontSize(FontSize.md).fontColor(this.getColors().textSecondary)
.padding(Spacing.xs).onClick(() => this.clearFilters())
}
// 计数显示
if (!this.searchKeyword && !this.activeTag) {
Text(String(this.statsSummary.total) + '个')
.fontSize(FontSize.xs).fontColor(this.getColors().textSecondary)
.margin({ left: Spacing.sm })
}
}
.width('100%').height(40)
.padding({ left: Spacing.sm, right: Spacing.sm })
.margin({ left: Spacing.md, right: Spacing.md, bottom: Spacing.sm })
.backgroundColor(this.getColors().surface)
.borderRadius(BorderRadius.lg)
.border({ width: 1, color: this.getColors().border })

搜索框的三种状态
| 状态 | 条件 | 显示内容 |
|---|---|---|
| 默认 | 无搜索词 + 无标签筛选 | 搜索图标 + “搜索…” + 总数 |
| 搜索中 | 有搜索词 | 搜索图标 + 输入内容 + ✕ 清除 |
| 标签筛选中 | activeTag 非空 | 搜索图标 + “搜索…” + ✕ 清除 |
搜索框同时承载关键词搜索和标签筛选两种功能——✕ 按钮同时清除两种筛选。 这是"一框多用"的设计:TextInput 处理自由文本,activeTag 处理标签点击,两者叠加过滤。
TextInput 的透明背景
TextInput({ placeholder: '搜索...' })
.backgroundColor('transparent')
搜索框背景透明——外层 Row 的 surface 色作为背景。 这样搜索框和外层容器视觉上融为一体,不会出现"白色输入框嵌在灰色容器里"的双层背景问题。
onChange 实时过滤
.onChange((value: string) => this.searchKeyword = value)
输入时实时更新 @State——不需要按搜索按钮。 每次输入变化都触发 getFiltered() 重新计算,列表立即更新。这是移动端搜索的标准交互——用户边输边看结果。
三、五维筛选系统
筛选标签的扩展
ForEach(['all', 'sealed', 'ready', 'opened', 'favorites'], (key: string) => {
Text(this.getFilterLabel(key))
.fontColor(this.activeFilter === key ? '#FFFFFF' : this.getColors().text)
.backgroundColor(this.activeFilter === key ? this.getColors().primary : this.getColors().surface)
.border({ width: 1, color: this.activeFilter === key ? this.getColors().primary : this.getColors().border })
.onClick(() => this.activeFilter = key)
})

五种筛选条件
| key | 标签 | 筛选逻辑 |
|---|---|---|
| all | 全部 | 显示所有胶囊 |
| sealed | 🔒等待 | 未开启 && 未到期 |
| ready | ✨可开 | 未开启 && 已到期 |
| opened | 📖已开 | 已开启 |
| favorites | ❤️收藏 | favorited === true |
新增"收藏"筛选——独立于状态筛选。 用户可能想看"所有收藏的胶囊",不管它是等待中还是已开启。收藏是一个跨状态的维度,不能用 isOpened 和 unlockAt 判断。
getFiltered 的筛选叠加
private getFiltered(): Capsule[] {
const now = Date.now()
let list: Capsule[] = []
if (this.activeFilter === 'all') {
list = this.capsules.slice()
} else if (this.activeFilter === 'sealed') {
list = this.capsules.filter((c: Capsule) => !c.isOpened && c.unlockAt > now)
} else if (this.activeFilter === 'ready') {
list = this.capsules.filter((c: Capsule) => !c.isOpened && c.unlockAt <= now)
} else if (this.activeFilter === 'opened') {
list = this.capsules.filter((c: Capsule) => c.isOpened)
} else if (this.activeFilter === 'favorites') {
list = this.capsules.filter((c: Capsule) => c.favorited)
}
// 关键词搜索叠加
if (this.searchKeyword) {
const kw = this.searchKeyword.toLowerCase()
list = list.filter((c: Capsule) =>
c.content.toLowerCase().includes(kw) ||
c.tags.some((t: string) => t.toLowerCase().includes(kw))
)
}
// 标签筛选叠加
if (this.activeTag) {
list = list.filter((c: Capsule) =>
c.tags.some((t: string) => t === this.activeTag)
)
}
list.sort((a: Capsule, b: Capsule) => b.createdAt - a.createdAt)
return list
}
三层筛选叠加:状态筛选 → 关键词搜索 → 标签筛选。 每一层都在上一层的结果上进一步过滤。用户可以同时使用"收藏+搜索关键词"——先筛选收藏,再在收藏中搜索。
clearFilters 的统一清除
private clearFilters(): void {
this.searchKeyword = ''
this.activeTag = ''
this.activeFilter = 'all'
}
✕ 按钮一键清除所有筛选——恢复到默认状态。 不需要用户分别清除搜索词和标签。
四、双视图切换
视图切换按钮
Text(this.viewMode === 'list' ? '⊞' : '☰')
.fontSize(20).width(40).height(40).textAlign(TextAlign.Center)
.onClick(() => this.viewMode = this.viewMode === 'list' ? 'grid' : 'list')
用文字符号作为图标——⊞ 代表网格,☰ 代表列表。 不需要加载图片资源,纯文本渲染。点击在两种视图间切换。
列表视图 vs 网格视图
| 特性 | 列表视图 | 网格视图 |
|---|---|---|
| 布局 | Column 垂直排列 | Flex 2×2 网格 |
| 卡片宽度 | 100% | 48% |
| 信息密度 | 高(完整内容预览) | 低(仅图标+短文字) |
| 适用场景 | 阅读内容详情 | 快速浏览状态 |
网格视图的卡片设计
Column() {
if (getCapsuleState(capsule) === CapsuleState.SEALED) {
Text('🔒').fontSize(36)
} else {
Text(capsule.mood).fontSize(36)
}
if (getCapsuleState(capsule) === CapsuleState.SEALED) {
Text(this.countdownText(capsule.unlockAt))
.fontSize(11).fontColor(this.getColors().primary)
.fontWeight(FontWeight.Bold).margin({ top: 4 })
} else if (getCapsuleState(capsule) === CapsuleState.READY) {
Text('✨可开启').fontSize(11)
.fontColor('#F59E0B').fontWeight(FontWeight.Bold)
.margin({ top: 4 })
} else {
Text(capsule.content.length > 15
? capsule.content.substring(0, 15) + '…'
: capsule.content)
.fontSize(10).fontColor(this.getColors().textSecondary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
}
.width('48%').height(100)
.justifyContent(FlexAlign.Center)
.backgroundColor(getCapsuleState(capsule) === CapsuleState.READY
? '#FFF8E1' : this.getColors().surface)
.borderRadius(BorderRadius.md)

网格卡片的三种状态显示
| 状态 | 图标 | 下方文字 | 背景色 |
|---|---|---|---|
| SEALED | 🔒 | 倒计时(primary色) | surface |
| READY | capsule.mood | ✨可开启(黄色) | #FFF8E1(暖黄) |
| OPENED | capsule.mood | 内容前15字+… | surface |
READY 状态的卡片有特殊背景色——暖黄色 #FFF8E1。 用户扫一眼网格就能看到哪些胶囊可以开启——黄色卡片在一堆白色卡片里非常醒目。
getCapsuleState 函数
// 从 model/Capsule.ets 导入
function getCapsuleState(capsule: Capsule): CapsuleState {
if (capsule.isOpened) return CapsuleState.OPENED
if (capsule.unlockAt <= Date.now()) return CapsuleState.READY
return CapsuleState.SEALED
}
状态计算逻辑和首页 getFiltered 一致——但封装成了独立函数。 网格视图和详情页都需要判断状态,复用这个函数避免重复代码。
五、空状态的引导设计
基础版 vs 优化版
// 基础版:简单文字
Column() {
Text('🔮').fontSize(64)
Text('还没有胶囊').fontSize(FontSize.lg)
Text('切换到「封印」标签创建第一个').fontSize(FontSize.sm)
}
// 优化版:完整引导
Column() {
Text('🔮').fontSize(72)
Text('封印你的第一个胶囊')
.fontSize(FontSize.lg).fontWeight(FontWeight.Bold)
Text('写下想对未来自己说的话\n设定一个未来的时间\n等待时光让它成熟')
.fontSize(FontSize.sm).textAlign(TextAlign.Center).lineHeight(22)
Row() {
Text('✍️ 开始封印').fontSize(FontSize.md)
.fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.backgroundColor(this.getColors().primary).borderRadius(BorderRadius.full)
.onClick(() => this.currentTab = 'create')
}
空状态的四个层次
| 层次 | 内容 | 作用 |
|---|---|---|
| 图标 | 🔮 72px | 吸引注意力 |
| 标题 | “封印你的第一个胶囊” | 告诉用户该做什么 |
| 描述 | 三行引导文字 | 解释产品价值 |
| 按钮 | “✍️ 开始封印” | 直接跳转到封印 Tab |
从"告知"变成"引导"——不只是说"没有数据",而是告诉用户"你可以做什么"。 按钮直接跳转到封印 Tab(this.currentTab = 'create'),减少用户操作路径。
描述文字的三行结构
写下想对未来自己说的话 ← 功能价值
设定一个未来的时间 ← 操作步骤
等待时光让它成熟 ← 情感共鸣
三行文字分别回答"是什么"“怎么做”“为什么”。 这是产品文案的经典结构——功能+步骤+情感。最后一行"等待时光让它成熟"把"等待"这件事变得浪漫,而不是枯燥的倒计时。
六、标题栏的两个按钮
Row() {
Text('时光胶囊').fontSize(FontSize.title)
.fontColor(this.getColors().text).fontWeight(FontWeight.Bold)
Blank()
Text(this.viewMode === 'list' ? '⊞' : '☰')
.fontSize(20).width(40).height(40)
.onClick(() => this.viewMode = this.viewMode === 'list' ? 'grid' : 'list')
Text('🎲')
.fontSize(20).width(40).height(40)
.onClick(() => this.goToShuffle())
}
两个按钮的分工
| 按钮 | 图标 | 功能 |
|---|---|---|
| 视图切换 | ⊞ / ☰ | 列表/网格切换 |
| 记忆洗牌 | 🎲 | 跳转 MemoryShuffle |
标题栏承载了两个不常用但需要快速访问的功能。 视图切换和洗牌不是核心操作(核心是筛选和查看),但放在标题栏比放在筛选栏或底部更合适——不占用筛选空间,不影响主流程。
七、搜索与筛选的交互优先级
用户操作路径:
1. 打开时间线 Tab → 显示所有胶囊(按时间降序)
2. 点击筛选标签 → 按状态/收藏筛选
3. 输入搜索关键词 → 在筛选结果中进一步过滤
4. 点击标签 → 在搜索结果中再过滤
5. 点击 ✕ → 清除所有筛选,回到步骤 1
筛选和搜索是"且"的关系——同时生效。 用户可以"收藏中的+搜索’旅行’"——先筛选收藏,再在收藏中搜索包含"旅行"的胶囊。
搜索的范围
list.filter((c: Capsule) =>
c.content.toLowerCase().includes(kw) ||
c.tags.some((t: string) => t.toLowerCase().includes(kw))
)
搜索覆盖两个字段:内容和标签。 不搜索心情——因为心情是 emoji 不是文字。不搜索 ID——因为用户不会搜索 ID。toLowerCase() 保证大小写不敏感。
八、数据流的完整链路
用户输入搜索词
→ searchKeyword 更新
→ getFiltered() 重新计算
→ 三层层叠筛选(状态→关键词→标签)
→ 返回过滤后的列表
→ ForEach 重新渲染
五个 @State 变量协同
| 变量 | 作用 | 触发筛选 |
|---|---|---|
| activeFilter | 状态/收藏筛选 | 是 |
| searchKeyword | 关键词搜索 | 是 |
| activeTag | 标签筛选 | 是 |
| viewMode | 视图切换 | 否(只影响渲染方式) |
| capsules | 原始数据 | 是(数据源) |
五个变量中三个参与筛选,一个影响渲染方式,一个是数据源。 筛选逻辑和视图逻辑完全分离——改筛选不影响视图,改视图不影响筛选。
九、整体嵌套结构
Column buildTimeline 容器
├→ Row (标题) 标题 + 视图切换 + 洗牌
├→ Row (搜索框) 搜索图标 + TextInput + 清除/计数
├→ Scroll + Row (筛选标签) 五种筛选的横向滚动
└→ 内容区(三选一)
├→ 空状态 Column 无胶囊时的引导
├→ Scroll + Flex (网格视图) viewMode === 'grid'
└→ List + ForEach (列表视图) viewMode === 'list'
内容区用 if-else 三选一——空状态、网格、列表互斥。 空状态优先判断(capsules.length === 0),有数据时再判断视图模式。
嵌套深度 5 层
Column → Scroll → Row → ForEach → Text (筛选标签)
Column → Scroll → Flex → Column → Text (网格卡片)
5 层嵌套——比首页更深。 因为时间线增加了搜索和双视图,嵌套层级增加了。但在 ArkTS 里 5 层是可接受的——超过 7 层才需要考虑拆分。
更多推荐


所有评论(0)