HarmonyOS 6商城开发学习:搜索历史“展开/收起“的正确姿势——Flex折行裁剪与状态驱动的动效
购物比价/电商搜索页有一个几乎必做的交互细节:用户搜过的关键词以标签(Tag)形式列在搜索框下方,一行排不开就自动折行;词多了不能全堆出来,要做折叠(只露N行)→ 点"更多"展开 → 点"收起"缩回去。
这事儿看起来只是"显示/隐藏"几个词,但真正做成商用水准时,你会遇到两个躲在角落里的问题:
-
标签是自动换行的:你没法用简单的
slice(0, 6)切几条就完事,因为"第一行能放几个"取决于每个标签的实际宽度(不同词长度不同)。 -
折叠必须"按行裁":裁得不干净,要么漏半行出来,要么裁太狠把第二行的半截露个头——视觉就破了。
华为官方在购物比价行业实践里给出的解法,核心不在数组切片,而在 Flex 折行布局 + constraintSize限高 + clip裁边 这一组合。下面把它拆透。
一、先对齐:搜索历史区的DOM结构应该长什么样
不管你视觉稿多花哨,搜索历史区本质上就是一个标签池:
搜索框
──────────────────────────────
[手机] [耳机] [充电宝] [小米14]
[显卡] [DDR5] [散热支架] ▼ 更多
──────────────────────────────
交互规格通常长这样:
|
状态 |
表现 |
|---|---|
|
折叠 |
只露 |
|
展开 |
解除高度限制,全部历史可见 |
|
切換 |
"更多 ▼ / 收起 ▲" 按钮,只在"总内容行数 > 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→ 驱动 constraintSize从 maxHeight→ undefined(解除限制),视觉上就是一个顺滑地撑开/合拢。
四、最小可工作的结构代码(克制版,只保留骨架)
下面给一个"能看懂全貌、又不淹没你"的版本——所有数值你按自己设计稿调。
// 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伪折叠方案的本质区别(面试/复盘最爱问)
|
|
|
|
|---|---|---|
|
是否真的按行裁 |
❏ 按个数估,会露半词 |
✅ 按像素高度裁,整齐 |
|
标签宽度不等时 |
容易漏半截 |
裁得干净 |
|
展开/收起动画 |
需要自己拼两套节点 |
同一套节点,只是 height 约束变 |
|
布局一致性 |
两套渲染路径,容易行为漂移 |
一套渲染路径,状态只控约束 |
这也是为什么华为把它放在"关键场景"里讲——它不是"ArkUI小技巧",而是搜索页可信度的底线:用户看到折行裁切干净,才觉得这是个认真做的App。
七、易踩坑清单(省你两小时调试)
-
忘写
clip(true)constraintSize只限制布局尺寸,不裁边——标签圆角或长词会溢出来。加上clip才收口。 -
动画直接改数值不包
animateTothis.expanded = !this.expanded裸写不是不行,但裁切变化会"咔"一下跳;包进animateTo后 ArkUI 会对constraintSize的高度变化插值过渡。 -
maxHeight算漏了 gap一行高 36、gap 8、两行 =
36×2 + 8 = 80,不是36×2。漏算 gap 的后果是:裁完你发现第二行字被削头——肉眼看着像"被啃了一口"。 -
搜索历史来自Preferences,初始化时序问题
如果
history是 Preferences 异步读的,aboutToAppear里算maxHeight那步没关系(它是纯常数),但showToggle的初始值最好在数据回来后再判一次,避免按钮闪一下。
八、总结
搜索历史的"展开/收起"在购物比价/商城页里,看似是小控件,但它检验的是你对 ArkUI 布局系统的理解层级:
-
Tag 是折行问题 → 用 Flex Wrap
-
折叠是按行裁 → 用 constraintSize 限高 + clip 裁边
-
展开/收起是状态驱动 → 用 animateTo 让 constraintSize 在"有限/无限"之间过渡
-
别用 slice 假装裁行
把它写成 SearchHistoryTags一个组件,props 只接 history: string[]和一个 onPick,搜索框只管自己的事——这个搜索页就稳了。
HarmonyOS 6 商城开发里,"裁得干净"往往比"功能齐全"更让用户觉得专业。 搜索历史就是第一个被放大镜对准的地方。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)