购物比价/电商搜索页有一个几乎必做的交互细节:用户搜过的关键词以标签(Tag)形式列在搜索框下方,一行排不开就自动折行;词多了不能全堆出来,要做折叠(只露N行)→ 点"更多"展开 → 点"收起"缩回去

这事儿看起来只是"显示/隐藏"几个词,但真正做成商用水准时,你会遇到两个躲在角落里的问题:

  1. 标签是自动换行的:你没法用简单的 slice(0, 6)切几条就完事,因为"第一行能放几个"取决于每个标签的实际宽度(不同词长度不同)。

  2. 折叠必须"按行裁":裁得不干净,要么漏半行出来,要么裁太狠把第二行的半截露个头——视觉就破了。

华为官方在购物比价行业实践里给出的解法,核心不在数组切片,而在 Flex 折行布局 + constraintSize限高 + clip裁边​ 这一组合。下面把它拆透。


一、先对齐:搜索历史区的DOM结构应该长什么样

不管你视觉稿多花哨,搜索历史区本质上就是一个标签池

搜索框
──────────────────────────────
[手机] [耳机] [充电宝] [小米14]
[显卡] [DDR5] [散热支架]    ▼ 更多
──────────────────────────────

交互规格通常长这样:

状态

表现

折叠

只露 maxRows行(常见 maxRows=2),多出部分不可见

展开

解除高度限制,全部历史可见

切換

"更多 ▼ / 收起 ▲" 按钮,只在"总内容行数 > maxRows"时才出现

所以你需要的不是一个"过滤数组",而是一个能折行、能限高、能裁边的容器


二、为什么很多人第一版写出来"能用但经不起推敲"

最常见的第一版思路是:

// ❌ 能跑,但错的思路:按数量切
let showTags = this.expanded
  ? this.history
  : this.history.slice(0, 6)

Column() {
  Flex({ wrap: FlexWrap.Wrap }) {
    ForEach(showTags, (word) => {
      Text(word).padding(...)
    })
  }
}

问题在于

  • slice(0, 6)是按"个数"一刀切,但 Flex 折行是按"宽度"排版的——你可能切到第6个正好被挤到第三行开头,结果折叠态第三行其实只露半个词,看起来很业余。

  • 更糟的是:你隐藏的那部分并没有被视觉裁掉,只是没渲染——当用户点展开再收起,Flex 的排版跳动感很难压住。

  • 标签宽度可变时,你根本不知道"N行"对应多少个词,你真正想限制的是高度,不是个数。


三、正解:Flex + constraintSize + clip——官方方案的骨架

官方文档的实现思路可以浓缩成两步(这也是它为什么比"切数组"高级):

第一步:测高度,定阈值

在组件初始化阶段(aboutToAppear)算出折叠态允许的最大高度

maxHeight = maxRows × 单行预估高度(含间距)

比如你标签行高是 tagHeight=36vp,行间距 gap=8vp,两行的话:

maxHeight = 2 × 36 + (2-1) × 8 = 80vp

然后判断:

  • history.length > 0 && 如果排版后确实超行→ 显示"更多"按钮

  • 初始 curHeight = maxHeight(折叠态)

第二步:用 constraintSize + clip 做"裁刀"

Stack() {
  Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
    ForEach(this.history, (word: string) => {
      this.TagItem(word)
    })
  }
  .constraintSize({
    maxHeight: this.expanded ? undefined : this.maxHeight
  })
  .clip(true)   // ← 关键:把超出的部分裁掉,不裁就漏边
}

把这两句当咒语记住:

  • constraintSize({ maxHeight }):告诉 Flex 容器"你最多撑这么高"

  • clip(true):撑不下的部分不是"挤出去",而是裁掉不留残影

点"更多/收起"时只做一件事:

toggle() {
  animateTo({ duration: 240, curve: Curve.EaseOut }, () => {
    this.expanded = !this.expanded
  })
}

动画交给 animateTo驱动 expanded→ 驱动 constraintSizemaxHeightundefined(解除限制),视觉上就是一个顺滑地撑开/合拢


四、最小可工作的结构代码(克制版,只保留骨架)

下面给一个"能看懂全貌、又不淹没你"的版本——所有数值你按自己设计稿调。

// SearchHistoryTags.ets
@Component
export struct SearchHistoryTags {
  @State history: string[] = [
    '手机', '耳机', '充电宝', '小米14',
    '显卡', 'DDR5内存', '散热支架', 'Type-C线'
  ]

  /* ---------- 折叠参数 ---------- */
  private readonly MAX_ROWS = 2
  private readonly ROW_HEIGHT = 36   // tag高度(vp)
  private readonly GAP = 8            // 行间距(vp)
  readonly maxHeight = this.MAX_ROWS * this.ROW_HEIGHT + (this.MAX_ROWS - 1) * this.GAP

  @State expanded: boolean = false
  @State showToggle: boolean = true  // 初始可先true;精细做法见后

  build() {
    Column({ space: 8 }) {

      /* ---- 标签区:Flex + 限高裁剪 ---- */
      Stack() {
        Flex({
          wrap: FlexWrap.Wrap,
          alignItems: ItemAlign.Center,
          space: { row: 10, column: this.GAP }
        }) {
          ForEach(this.history, (word) => {
            Text(word)
              .fontSize(13)
              .padding({ horizontal: 12, vertical: 6 })
              .backgroundColor('#F5F5F5')
              .borderRadius(16)
              .onClick(() => this.onPick(word))
          })
        }
        .constraintSize({
          maxHeight: this.expanded ? undefined : this.maxHeight
        })
        .clip(true)   // 裁掉MAX_ROWS行之外的
      }

      /* ---- 展开/收起按钮 ---- */
      if (this.history.length > 4) {   // 粗判:词足够多才出现
        Row() {
          Text(this.expanded ? '收起 ▲' : '更多 ▼')
            .fontSize(13)
            .fontColor('#007DFF')
        }
        .padding({ top: 4 })
        .onClick(() => {
          animateTo({ duration: 240, curve: Curve.EaseOut }, () => {
            this.expanded = !this.expanded
          })
        })
      }
    }
  }

  onPick(word: string) {
    // 把 word 送进搜索框 / 直接触发搜索
    console.log('pick history:', word)
  }
}

你注意到的关键事实是:ForEach从来没被 slice 过

数组永远是全量的,视觉裁切由 constraintSize + clip负责——这才是"按行裁"而不是"按个数估"。


五、一个常被忽略的细节:showToggle 要不要"精确计算行数"

上面代码里我用 if (history.length > 4)粗判按钮显隐——因为通常够了。但如果你追求严丝合缝(比如标签极少时不应出现"更多"就算理论行数=1),思路是:

用 Flex 排版完成后的"实际内容高度"跟你算的 maxHeight 比一下,大于就显示按钮。

实现上可以用一个间接法:

给 Flex 外包一层 Column,用 onAreaChange拿它实际高度(或在折叠态 maxHeight下,看最后一词是否被裁判断)——但多数商城搜索页没必要搞这么重,maxRows是设计定值,你靠"历史数量/平均词长"的经验阈值就已经能覆盖 99%。

一句话:按钮显隐判的是"会不会超行",不是"数组长不长"。


六、它和 slice伪折叠方案的本质区别(面试/复盘最爱问)

slice(0, N)伪折叠

Flex + constraintSize + clip真折叠

是否真的按行裁

❏ 按个数估,会露半词

✅ 按像素高度裁,整齐

标签宽度不等时

容易漏半截

裁得干净

展开/收起动画

需要自己拼两套节点

同一套节点,只是 height 约束变

布局一致性

两套渲染路径,容易行为漂移

一套渲染路径,状态只控约束

这也是为什么华为把它放在"关键场景"里讲——它不是"ArkUI小技巧",而是搜索页可信度的底线:用户看到折行裁切干净,才觉得这是个认真做的App。


七、易踩坑清单(省你两小时调试)

  1. 忘写 clip(true)

    constraintSize只限制布局尺寸,不裁边——标签圆角或长词会溢出来。加上 clip才收口。

  2. 动画直接改数值不包 animateTo

    this.expanded = !this.expanded裸写不是不行,但裁切变化会"咔"一下跳;包进 animateTo后 ArkUI 会对 constraintSize的高度变化插值过渡。

  3. maxHeight算漏了 gap

    一行高 36、gap 8、两行 = 36×2 + 8 = 80,不是 36×2。漏算 gap 的后果是:裁完你发现第二行字被削头——肉眼看着像"被啃了一口"。

  4. 搜索历史来自Preferences,初始化时序问题

    如果 history是 Preferences 异步读的,aboutToAppear里算 maxHeight那步没关系(它是纯常数),但 showToggle的初始值最好在数据回来后再判一次,避免按钮闪一下。


八、总结

搜索历史的"展开/收起"在购物比价/商城页里,看似是小控件,但它检验的是你对 ArkUI 布局系统的理解层级:

  • Tag 是折行问题 → 用 Flex Wrap

  • 折叠是按行裁 → 用 constraintSize 限高 + clip 裁边

  • 展开/收起是状态驱动 → 用 animateTo 让 constraintSize 在"有限/无限"之间过渡

  • 别用 slice 假装裁行

把它写成 SearchHistoryTags一个组件,props 只接 history: string[]和一个 onPick,搜索框只管自己的事——这个搜索页就稳了。

HarmonyOS 6 商城开发里,"裁得干净"往往比"功能齐全"更让用户觉得专业。​ 搜索历史就是第一个被放大镜对准的地方。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐