
前言
横向分类栏看起来简单,实际很容易写得别扭:选中态不明显、内容宽度不稳定、分类多了之后首尾被裁掉。我写 HarmonyOS7 分类栏时,会先保证三件事:可横滑、选中态清晰、切换后内容同步刷新。
分类栏不是装饰,它是列表数据的过滤入口,反馈必须快。
本文用“餐饮菜单分类”做例子。用户横向切换“早餐、午餐、咖啡、甜品”时,下方列表应该立即跟着变化;如果分类栏只是视觉组件,没有驱动数据刷新,就会让用户误以为点击没生效。
为什么这个问题经常被写乱
横向滚动分类栏 这类内容很容易被写成“代码能跑就算讲完了”,但对初学者来说,这恰恰是最不够的地方。真正让人卡住的,往往不是某个组件名记不住,而是不知道这段代码为什么要这样拆、状态为什么要这样放、以后需求变化时应该从哪里改。
所以这篇文章不只想给你一个能跑的例子,更想把背后的判断过程讲清楚。你只要把这个判断过程吃透,后面自己改页面、补需求、查问题时,心里会稳很多。
设计要点

| 细节 | 建议 |
|---|---|
| 分类宽度 | 根据文字自适应,保留左右 padding |
| 选中态 | 背景和文字颜色同时变化 |
| 内容刷新 | 用 selectedId 驱动,不直接操作列表组件 |
| 首尾间距 | Scroll 内部 Row 负责统一间距 |
实现步骤
- 用数组维护分类,不把分类项写死在布局里。
- 用
selectedId作为唯一选中状态。 - 分类项宽度跟随文字,但左右 padding 保持一致。

- 点击分类时同时更新选中态和内容列表。
- 分类很多时关闭滚动条,但保留首尾安全间距。
ArkUI/ArkTS 示例
class CategoryItem {
id: number = 0
name: string = ''
constructor(id: number, name: string) {
this.id = id
this.name = name
}
}
class FoodItem {
id: number = 0
categoryId: number = 0
name: string = ''
price: string = ''
constructor(id: number, categoryId: number, name: string, price: string) {
this.id = id
this.categoryId = categoryId
this.name = name
this.price = price
}
}
@Entry
@Component
struct CategoryScrollPage {
@State selectedId: number = 1
private categories: CategoryItem[] = [
new CategoryItem(1, '全部'), new CategoryItem(2, '早餐'), new CategoryItem(3, '午餐'),
new CategoryItem(4, '咖啡'), new CategoryItem(5, '甜品'), new CategoryItem(6, '夜宵')
]
private foods: FoodItem[] = [
new FoodItem(1, 2, '鸡蛋三明治', '¥18'),
new FoodItem(2, 3, '照烧鸡腿饭', '¥32'),
new FoodItem(3, 4, '冰美式', '¥16'),
new FoodItem(4, 5, '芝士蛋糕', '¥22')
]
private filteredFoods(): FoodItem[] {
if (this.selectedId === 1) {
return this.foods
}
return this.foods.filter((item: FoodItem) => item.categoryId === this.selectedId)
}
@Builder
CategoryChip(item: CategoryItem) {
Text(item.name)
.fontSize(14)
.fontColor(this.selectedId === item.id ? '#FFFFFF' : '#333333')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.selectedId === item.id ? '#1F6FEB' : '#FFFFFF')
.borderRadius(18)
.onClick(() => { this.selectedId = item.id })
}
build() {
Column({ space: 14 }) {
Text('菜单分类').fontSize(24).fontWeight(FontWeight.Bold)
Scroll() {
Row({ space: 8 }) {
ForEach(this.categories, (item: CategoryItem) => {
this.CategoryChip(item)
}, (item: CategoryItem) => item.id.toString())
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
List({ space: 8 }) {
ForEach(this.filteredFoods(), (food: FoodItem) => {
ListItem() {
Row() {
Text(food.name).fontSize(16).fontWeight(FontWeight.Medium)
Blank()
Text(food.price).fontSize(15).fontColor('#D9480F')
}
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(10)
}
}, (food: FoodItem) => food.id.toString())
}
.layoutWeight(1)
}
.padding({ top: 16 })
.backgroundColor('#F5F6FA')
.height('100%')
}
}
关键代码说明
ScrollDirection.Horizontal 明确横向滚动,配合 Row 控制分类之间的间距。
selectedId 是唯一选择状态。不要给每个分类都维护一个 selected,否则同步成本很高。
选中态同时改变文字和背景,比只改颜色更容易被用户识别。
filteredFoods() 让内容跟着分类状态派生出来。真实项目里这里可以替换成接口请求,但入口仍然应该是 selectedId 变化。
常见坑
- 分类项文字长短不一时,不要固定同一个宽度,否则短文案会显得松散,长文案会被截断。
- 横向滚动栏要有左右 padding,首尾分类贴边会显得很粗糙。
- 切换分类后如果列表为空,要显示空状态,不要留一片空白。
- 分类栏只是筛选入口,不建议在每个分类项里直接写复杂请求逻辑。
分类栏的状态边界
写横向分类栏时,我会把它当成一个小型筛选器,而不是单纯的一排按钮。分类项只负责把 selectedId 改成自己的 id,真正的数据变化交给 filteredFoods() 这类派生方法。这样做的好处是,后面无论改成本地筛选还是接口请求,分类栏本身都不用大改。
新手容易踩的坑,是在每个分类项里同时写选中样式、请求逻辑和列表刷新。短期看能跑,分类一多就会变得难维护。更稳的做法是让点击事件只表达“用户选了哪个分类”,其他逻辑围绕这个状态展开。
如果分类来自后端,还要特别注意 id 的稳定性。ForEach 的 key 不要用下标,分类顺序调整后下标会变,选中态和滚动复用都可能出现怪问题。
小结
HarmonyOS7 横向分类栏要写得舒服,重点不是组件多复杂,而是状态和视觉反馈稳定。分类越多,越要让用户明确知道自己当前选中了什么。


被折叠的 条评论
为什么被折叠?



