极简标签流:ArkUI 让鸿蒙搜索历史页面清爽得像热搜榜


实例:搜索历史记录(SearchHistory)|风格:极简标签流
一、设计理念:把历史做成「热搜榜」
搜索历史页面是极简主义的典范——它只有一个任务:让用户快速点回之前搜过的词。页面越简单越好,任何多余元素都是干扰。实例 7 的风格定为「极简标签流」,灵感来自微博热搜榜、浏览器搜索联想:
- 标签流(Tag Flow):历史词是「可点击的胶囊标签」,像热搜词一样平铺,点击即重新搜索;
- 无卡片堆砌:不用传统的列表卡片,用 Flex 自动换行的标签组——视觉密度高、信息一目了然;
- 一键清空:右上角「清空」入口,满足「我不想留历史」的隐私诉求。
页面信息架构:
- 标题栏:搜索历史标题 + 保留条数统计 + 一键清空;
- 搜索框:输入关键词,回车/点击搜索(记录入历史);
- 历史标签流:Flex 换行的胶囊标签,带搜索次数徽标,长按删除单条;
- 搜索时间线:极简的「词 + 时间」列表,展示最近搜索时序。
二、页面骨架:Column 三层结构
build() {
Column() {
// 标题栏
Row() { ... }
// 搜索框
Row({ space: 8 }) { TextInput + Button }
// 历史标签流
Column() {
Text('最近搜索')
Flex({ wrap: FlexWrap.Wrap }) { 标签 }
}
// 搜索时间线
Column() {
Text('搜索时间线')
ForEach(时间行)
}
}
.width('100%').height('100%').backgroundColor('#F8FAFC')
}
无 Stack(无悬浮元素)、无 Scroll 包裹(内容天然短,一屏容纳),Column 直落三层。极简页面的布局同样极简。
三、标题栏与搜索框
标题栏右侧是「清空」入口:
Row() {
Column() {
Text('🔍 搜索历史').fontSize(22).fontWeight(FontWeight.Bold)
Text(`保留最近 ${this.histories.length} 条`).fontSize(12).fontColor('#999999').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('清空').fontSize(14).fontColor('#EF4444').onClick(() => this.clearAll())
}.width('100%').padding({ left: 16, right: 16, top: 12 })
「保留最近 N 条」的副标题直接绑定 histories.length——动态展示当前保留条数,与数据层的 MAX_KEEP=10 呼应。「清空」红色文字独立入口,触发确认弹窗(7-4 会讲)。
搜索框是 Row 组合(输入框 + 按钮):
Row({ space: 8 }) {
TextInput({ placeholder: '输入关键词搜索…', text: this.keyword })
.layoutWeight(1).height(40)
.backgroundColor(Color.White).borderRadius(20)
.onChange((v: string) => this.keyword = v)
.onSubmit(() => this.doSearch(this.keyword))
Button('搜索').height(40).backgroundColor('#3B82F6').fontColor(Color.White)
.onClick(() => this.doSearch(this.keyword))
}.width('94%').margin({ top: 12 })
两个触发入口:.onSubmit(键盘回车)和按钮点击都调用 doSearch——手机键盘的「搜索」键是高频操作,必须支持。搜索流程:doSearch 先记录关键词入历史(addKeyword),再刷新列表,最后 Toast 提示「🔍 搜索「xxx」」。
四、历史标签流:Flex 换行胶囊
核心区块——历史词用 Flex 自动换行的胶囊标签展示:
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.histories, (h: SearchHistory) => {
Row({ space: 6 }) {
Text(h.keyword).fontSize(14)
if (h.count > 1) {
Text(`${h.count}次`).fontSize(10).padding({ left: 5, right: 5, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor('#DBEAFE').fontColor('#3B82F6')
}
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(18).backgroundColor(Color.White)
.margin({ right: 8, bottom: 8 })
.onClick(() => this.doSearch(h.keyword))
.gesture(
LongPressGesture()
.onAction(() => this.deleteOne(h.id))
)
}, (h: SearchHistory) => `${h.id}-${h.keyword}`)
}.width('100%').margin({ top: 12 })
设计细节:
- Flex wrap 自动换行:
Flex({ wrap: FlexWrap.Wrap })让标签一行放不下自动换行——这就是「标签流」的核心布局,无需手动管理换行; - 次数徽标:
h.count > 1时显示「N次」蓝色小徽标(背景#DBEAFE淡蓝)——搜索热度可视化,超过 1 次才显示避免视觉噪音; - 点击重搜:
onClick(() => this.doSearch(h.keyword))——点标签 = 再次搜索该词(会刷新它的时间戳和次数,上浮到最前); - 长按删除:
.gesture(LongPressGesture().onAction(...))——长按标签删除单条历史,手势操作保持界面简洁; - 白底胶囊:白底 + 圆角 18,在浅灰背景(
#F8FAFC)上形成清爽的「热搜词」质感。
为什么用 Flex 而不是 List? 标签数量少(最多 10 个)、大小不一(词长短不同)、需要自动换行——Flex 的 wrap 模式是天然选择;List 适合「同构、可滚动」的条目。布局组件选型看数据形态:同构条目用 List,异构标签用 Flex。
五、空态设计:搜索历史为空时的引导
当历史为空(首次使用或清空后),标签流区域显示引导:
if (this.histories.length === 0) {
Column() {
Text('🎉').fontSize(40)
Text('暂无搜索历史').fontSize(14).fontColor('#999999').margin({ top: 8 })
}.width('100%').margin({ top: 40 })
}
🎉 emoji + 「暂无搜索历史」——空态不只是空白,而是友好的引导文案。每个列表都要设计空态,这是生产级页面的基本素养(实例 3 的「本月暂无支出」同款思路)。
六、搜索时间线:极简的时序展示
标签流下方是「搜索时间线」——一个更详细的时序列表:
Column() {
Text('搜索时间线').fontSize(16).fontWeight(FontWeight.Bold)
ForEach(this.histories, (h: SearchHistory) => {
Row({ space: 10 }) {
Text('·').fontSize(20).fontColor('#3B82F6')
Text(h.keyword).fontSize(14).layoutWeight(1)
Text(this.fmtTime(h.searchTime)).fontSize(11).fontColor('#999999')
Text('✕').fontSize(14).fontColor('#CCCCCC').onClick(() => this.deleteOne(h.id))
}
.width('100%').padding({ top: 8, bottom: 8 })
}, (h: SearchHistory) => `t-${h.id}`)
}
.width('94%').padding(16).margin({ top: 12, bottom: 24 })
.alignItems(HorizontalAlign.Start)
每行:蓝色「·」节点 + 关键词 + 相对时间(今天 14:30 / 6月15日)+ ✕ 删除。时间格式化函数 fmtTime 做了「今天/历史」的智能区分:
private fmtTime(ts: number): string {
const d = new Date(ts);
const now = new Date();
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
if (sameDay) {
return `今天 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
return `${d.getMonth() + 1}月${d.getDate()}日`;
}
时间文案的友好化:今天的显示「今天 14:30」,更早的显示「6月15日」——比原始时间戳或完整日期更贴合「最近搜索」的轻量语境。
七、刷新与数据流
async refresh(): Promise<void> {
try {
await SearchHistoryDao.initSeedData(this.context);
this.histories = await SearchHistoryDao.queryAll(this.context);
} catch (e) {
promptAction.showToast({ message: `加载失败: ${e}` });
}
}
搜索、删除、清空后都刷新。doSearch 的完整流程:
async doSearch(kw: string): Promise<void> {
if (!kw.trim()) {
return;
}
await SearchHistoryDao.addKeyword(this.context, kw); // 记录(去重+刷新+淘汰)
await this.refresh(); // 重查列表
promptAction.showToast({ message: `🔍 搜索「${kw.trim()}」` });
}
先写后查——addKeyword 落库后再 queryAll,保证列表反映最新状态(新词出现在最前、重复词上浮、超限词被淘汰)。
八、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 极浅蓝灰 |
极简基调 |
| 标签 | 白底圆角 18 | 热搜词质感 |
| 次数徽标 | 淡蓝底 #DBEAFE 蓝字 |
热度可视化 |
| 时间线节点 | 蓝 · |
时序锚点 |
| 清空入口 | 红字 #EF4444 |
破坏操作色彩 |
| 主色 | #3B82F6 蓝 |
搜索按钮 |
九、文章小结
本篇文章完成了实例 7 的 UI 层:Flex 自动换行的历史标签流 + 次数徽标 + 长按删除 + 一键清空 + 时间线列表。极简设计让「点回历史词」这个核心任务零阻碍。布局要点是 Flex wrap 标签流(异构小条目)与 List(同构大条目)的选型差异,以及空态、相对时间等生产级细节。
下一篇(7-3)深入数据层,讲解去重插入、LIMIT 淘汰与时间排序的实现——那是本实例的技术核心。
十、交互细节:输入、回填与清空确认
本章补齐交互层面的三个高频细节:输入即搜索的防抖、点击历史词回填、清空历史的确认弹窗,并给出对应 ArkTS 实现。
1. 输入即搜索:防抖的取舍
搜索框有两个搜索时机:显式提交(回车/点按钮)与输入即搜(边输入边联想)。本实例默认显式提交——因为搜索历史是「记录」而非「联想」,每敲一个字符就写一次库会造成大量无效写入。若要做输入即搜,必须加防抖:
private searchTimer: number = -1;
onChange((v: string) => {
this.keyword = v;
if (this.searchTimer !== -1) {
clearTimeout(this.searchTimer);
}
this.searchTimer = setTimeout(() => {
if (v.trim()) {
this.doSearch(v);
}
}, 600); // 600ms 防抖:停止输入后才触发
})
防抖原理:连续输入时不断 clearTimeout 重置计时器,只有停止输入 600ms 后才真正执行一次搜索。注意 onDestroy 里要清理定时器,防止页面销毁后回调仍触发状态更新。
2. 点击历史词回填
点历史标签直接重搜虽然快,但用户看不到「词进了输入框」,反馈不够直观。更自然的交互是先回填再搜索:
onClick(() => {
this.keyword = h.keyword; // 回填输入框
this.doSearch(h.keyword); // 再触发搜索
})
@State keyword 与 TextInput({ text: this.keyword }) 双向联动——改状态即改输入框文案。回填让「点历史 = 复用上一次搜索」的语义更完整:输入框、历史流、Toast 三者状态一致。
3. 热词排行展示
次数徽标之外,「热搜榜」还可以按搜索次数降序展示 Top 词。UI 层做一份排序副本即可,不动数据层:
private get hotWords(): SearchHistory[] {
return [...this.histories].sort((a, b) => b.count - a.count);
}
| 展示形态 | 排序依据 | 适用场景 |
|---|---|---|
| 历史标签流 | 时间倒序(最近搜的在前) | 快速点回最近搜索 |
| 热词排行 | 次数降序(搜得多的在前) | 展示用户高频兴趣 |
两种形态共用同一份数据、不同的排序视角——排序是视图层的职责,数据层只保证「去重 + 上限 + 时间戳」正确。
4. 清空历史的确认交互
「清空」是破坏性操作,直接执行会让误触无法挽回,用 AlertDialog 确认:
clearAll(): void {
AlertDialog.show({
title: '清空搜索历史?',
message: '将删除全部历史记录,且不可恢复',
primaryButton: {
value: '取消', fontColor: '#999999',
action: () => {}
},
secondaryButton: {
value: '清空', fontColor: '#EF4444',
action: async () => {
await SearchHistoryDao.deleteAll(this.context);
await this.refresh();
promptAction.showToast({ message: '🗑 历史已清空' });
}
}
});
}
危险操作三层防护:红色按钮(色彩警示)→ 确认弹窗(二次确认)→ 清空后 Toast(结果反馈)。长按删除单条同理——手势本身已是误触门槛,可不加弹窗。
5. ArkTS 细节
- 定时器类型:
setTimeout返回值必须显式声明为number(ArkTS 禁用any),初始化为 -1 表示「无定时器」; - 排序副本:
[...this.histories]展开创建新数组再 sort,避免就地排序污染@State数据; - ForEach 键:历史流与时间线用
id组合键(${h.id}-${h.keyword}),避免重复词触发组件重建; - 回调清理:
onDestroy中clearTimeout,防页面销毁后定时器仍触发更新。
FAQ
Q:输入即搜会不会把中间状态也写进历史?
A:会——所以用 600ms 防抖只记录「停顿后的完整词」。若要更严格,可在 doSearch 里再加长度校验(如 ≥2 字才记录)。
Q:回填历史词后,重复点击同一条会怎样?
A:addKeyword 走「存在则更新」分支——时间戳刷新、次数 +1,记录仍是一条,不会产生重复标签。
Q:热词排行需要单独的数据库表吗?
A:不需要。次数已存在 count 字段,排序在 UI 层一行代码完成,多建表反而引入同步问题。
更多推荐




所有评论(0)