在这里插入图片描述
在这里插入图片描述

实例:搜索历史记录(SearchHistory)|风格:极简标签流

一、设计理念:把历史做成「热搜榜」

搜索历史页面是极简主义的典范——它只有一个任务:让用户快速点回之前搜过的词。页面越简单越好,任何多余元素都是干扰。实例 7 的风格定为「极简标签流」,灵感来自微博热搜榜、浏览器搜索联想:

  1. 标签流(Tag Flow):历史词是「可点击的胶囊标签」,像热搜词一样平铺,点击即重新搜索;
  2. 无卡片堆砌:不用传统的列表卡片,用 Flex 自动换行的标签组——视觉密度高、信息一目了然;
  3. 一键清空:右上角「清空」入口,满足「我不想留历史」的隐私诉求。

页面信息架构:

  1. 标题栏:搜索历史标题 + 保留条数统计 + 一键清空;
  2. 搜索框:输入关键词,回车/点击搜索(记录入历史);
  3. 历史标签流:Flex 换行的胶囊标签,带搜索次数徽标,长按删除单条;
  4. 搜索时间线:极简的「词 + 时间」列表,展示最近搜索时序。

二、页面骨架: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 })

设计细节

  1. Flex wrap 自动换行Flex({ wrap: FlexWrap.Wrap }) 让标签一行放不下自动换行——这就是「标签流」的核心布局,无需手动管理换行;
  2. 次数徽标h.count > 1 时显示「N次」蓝色小徽标(背景 #DBEAFE 淡蓝)——搜索热度可视化,超过 1 次才显示避免视觉噪音;
  3. 点击重搜onClick(() => this.doSearch(h.keyword))——点标签 = 再次搜索该词(会刷新它的时间戳和次数,上浮到最前);
  4. 长按删除.gesture(LongPressGesture().onAction(...))——长按标签删除单条历史,手势操作保持界面简洁;
  5. 白底胶囊:白底 + 圆角 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 keywordTextInput({ 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 细节

  1. 定时器类型setTimeout 返回值必须显式声明为 number(ArkTS 禁用 any),初始化为 -1 表示「无定时器」;
  2. 排序副本[...this.histories] 展开创建新数组再 sort,避免就地排序污染 @State 数据;
  3. ForEach 键:历史流与时间线用 id 组合键(${h.id}-${h.keyword}),避免重复词触发组件重建;
  4. 回调清理onDestroyclearTimeout,防页面销毁后定时器仍触发更新。

FAQ

Q:输入即搜会不会把中间状态也写进历史?
A:会——所以用 600ms 防抖只记录「停顿后的完整词」。若要更严格,可在 doSearch 里再加长度校验(如 ≥2 字才记录)。

Q:回填历史词后,重复点击同一条会怎样?
A:addKeyword 走「存在则更新」分支——时间戳刷新、次数 +1,记录仍是一条,不会产生重复标签。

Q:热词排行需要单独的数据库表吗?
A:不需要。次数已存在 count 字段,排序在 UI 层一行代码完成,多建表反而引入同步问题。

Logo

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

更多推荐