HarmonyOS 6商城开发学习:侧边分类栏“从顶排列+选中居中“的正确做法——放弃Tabs bar,用List+Scroller接管导航
做购物商城/比价App时,一级分类页基本都长一样:左边一条分类导航(侧边栏),右边展示对应分类的商品/筛选结果。很多团队第一反应是:
"用
Tabs + barPosition=Start + vertical不就完了?官方就叫侧边栏Tabs啊。"
但真正做进去会发现一个让人烦躁的现象:左边栏条目数量不多时,它总喜欢"竖直居中"排列,怎么调都做不到"从最顶部开始往下排",而且你也没法精确控制条目高度/间距的视觉细节。等你去看官方"行业常见问题"的解释就明白了:Tabs 的页签条(bar)在侧边模式下的布局策略是由Tabs内部调度的,barMode=Scrollable只能解决"可滚",解决不了"内容锚定到顶 + 未撑满时仍从顶开始"。
所以官方给出的结论是:侧边栏导航别用 Tabs 的 bar 来实现,改用 Row + List做导航栏,再用 Tabs仅承载右侧内容页(把原bar藏掉)。
一、问题本质:Tabs的bar不是给你"自由排版的左侧栏"
Tabs 的结构是:一个 bar 容器 + 一个内容容器,它的 bar 内部对齐方式、分配策略是为"页签栏"设计的(居中对齐/均匀分布/滚动跟随),不是为"业务左侧分类树"设计的。
当你写:
Tabs({ barPosition: BarPosition.Start })
.vertical(true)
.barMode(BarMode.Scrollable)
你得到的是一个可滚动的竖直页签条,但它:
-
不知道你希望"第一项贴顶"
-
不会因为条目总数没撑满就改变自己的垂直对齐策略
-
条目高度也受 Tabs 内部样式约束,不方便做"左边栏经典样式"(选中态左侧蓝条、圆角背景、固定行高/安全区顶底留白)
一句话:Tabs bar 是"页签控件",左侧分类栏是"导航列表控件"——职责不同,硬捏就会拧巴。
二、正确结构:Row 拆成两列,List 管左、Tabs 只管右
官方给的骨架非常清醒,翻译成工程语言就是三层:
Row() ← 整页:左右分布
├─ List(导航栏) ← 左侧:你的分类树,100%高度,可滚,你完全控布局
└─ Tabs(内容页) ← 右侧:只当"内容切换器",原bar藏掉(barWidth=0)
关键点三个:
-
左侧导航栏用
List:天然支持滚动、条目复用、scrollToIndex、精确的item高度/间距/选中态 -
右侧用
Tabs但barWidth(0)藏bar:你不需要它的bar了,导航切换由List的点击事件驱动(tabsController.changeIndex) -
选中项居中用
Scroller.scrollToIndex(index, smooth, ScrollAlign.CENTER):这才是"真正可控"的居中,而不是靠CSS式的对齐去赌
三、精简但完整的骨架(代码克制版)
下面这个版本把"能跑的逻辑"留着,把"样式细修"全去掉——你要的就是看清数据流和布局约束。
// pages/CategoryPage.ets
import { Scroller } from '@kit.ArkUI'
@Entry
@Component
struct CategoryPage {
/* ---- 数据 ---- */
private categories: string[] = [
'推荐','手机','耳机','充电宝','显示器',
'键盘','运动','家居','图书','零食'
]
@State cur: number = 0
/* ---- 左侧导航的滚动控制器 ---- */
private navScroller: Scroller = new Scroller()
/* ---- 右侧Tabs控制器 ---- */
private tabsCtrl: TabsController = new TabsController()
build() {
// 外层Row:左右布局;children整体撑满屏
Row() {
/* ========= 左侧导航栏 ========= */
List({ scroller: this.navScroller }) {
ForEach(this.categories, (cat, i) => {
ListItem() {
Row() {
// 选中指示条(左边蓝条)
Row().width(3).height('60%')
.backgroundColor(i === this.cur ? '#0A59F7' : 'transparent')
.borderRadius(2)
Text(cat)
.fontSize(14)
.fontColor(i === this.cur ? '#0A59F7' : '#333')
}
.width('100%')
.height(56) // 固定行高(这是你要的"精确感"来源)
.padding({ left: 12 })
.backgroundColor(i === this.cur ? '#EEF4FF' : '#F5F5F5')
}
.onClick(() => this.activate(i))
})
}
.scrollBar(BarState.Off)
.width(96) // 左侧栏固定宽度
.height('100%') // 关键:100%不是"wrap",是占满Row剩余交叉轴
// 可选:顶部避让状态栏
// .padding({ top: px2vp(statusBarHeight) })
/* ========= 右侧内容区(Tabs只当切换器) ========= */
Tabs({ controller: this.tabsCtrl }) {
ForEach(this.categories, (cat) => {
TabContent() {
// 右侧内容:商品网格/筛选骨架
Text(`当前分类:${cat}`)
.fontSize(20)
.padding(24)
}
})
}
.barWidth(0) // 藏掉原生bar(导航已交给List)
.vertical(true) // 内容页也可以是纵向逻辑(通常这里其实不需要vertical=true,除非你真纵向tab;一般横向切即可)
.barPosition(BarPosition.Start)
.onChange((idx: number) => {
// 反向同步:手指滑内容页时,导航态也跟着变
this.cur = idx
this.navScroller.scrollToIndex(idx, true, ScrollAlign.CENTER)
})
.layoutWeight(1) // 右侧吃掉剩余宽度
.height('100%')
.backgroundColor('#fff')
}
.alignItems(VerticalAlign.Top)
.width('100%')
.height('100%') // 这句是"自顶到底"的根基:外层占满
}
/* ---- 点击导航项:切换态 + 居中 + 切Tabs页 ---- */
activate(i: number) {
this.cur = i
// 1)导航栏滚动,让选中项居中
this.navScroller.scrollToIndex(i, true, ScrollAlign.CENTER)
// 2)右侧内容页切页
this.tabsCtrl.changeIndex(i)
}
}
这版代码里你要盯紧的只有四句话
|
语句 |
为什么关键 |
|---|---|
|
|
左侧栏要真的占满"从顶到底",前提就是外层Row也100%,否则'100%'是相对"内容"而不是"屏" |
|
|
告诉你:原bar我不用了,导航语义归List,Tabs退化成"内容切换器" |
|
|
让选中项滚到可视区正中,而不是你用offset硬算 |
|
|
右侧内容区自适应剩下的宽度(96固定 + 其余归右),否则你会看到"右侧没撑满,看起来不像整页" |
四、为什么 Tabs 侧边栏看起来"应该行"但就是不顺
把Tabs侧边栏拆穿,你会更安心不用它当左侧栏:
-
Tabs的bar不是List
它更像"内部排版盒",你传的
TabContent的标题/自定义tabBar 最终被放进它的bar容器里,对齐方式由Tabs内部管理,你很难强制它"第一项永远贴顶 + 没撑满也贴顶"。 -
barMode: Scrollable只解决"能不能滚",不解决"从哪里开始排"可滚≠顶部锚定;后者需要一个明确的首行偏移/对齐策略,而Tabs没把这个策略暴露成你想要的"分类树语义"。
-
选中居中更需要"外部滚动控制器"
scrollToIndex本身就是 List/Scroller 的语义;Tabs bar 的居中更多是"高亮跟踪",不是"我来帮你裁可视区"。与其跟它斗,不如直接用List接管——主动权在你手里。
五、最容易踩的三个坑(决定它"看起来像不像商城")
坑1:外层高度没立住,左侧栏看起来"没顶到底"
-
如果你的 Page/Column 里上面还有一个自定义顶部标题栏(比如48vp高),那左侧List的
'100%'会吃到"整页100%"然后被标题挤下去。 -
解法:要么标题放进 Navigation的titleBar(统一高度),要么左侧栏高度写成
calc(100% - 标题高)/ 用layoutWeight(1)去吃剩余空间。
坑2:刘海/状态栏区域,左侧第一项被"咬头"
这是折叠屏/挖孔屏最常见的问题。处理方式是:
// 获取状态栏高度(示例口径)
import window from '@ohos.window'
const ctx = getContext(this)
window.getLastWindow(ctx).then(win => {
this.statusBarH = px2vp(win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height)
})
然后在左侧List外套一个 Column或直接在List加 .padding({ top: this.statusBarH })做避让——别在item里做,否则你的"第一项贴顶"就和"安全区"打架。
坑3:快速连点时,scrollToIndex 和 changeIndex 打架
你在 activate(i)里连调两句状态切换,正常没问题,但如果你做了"右滑也同步左栏"(onChange → scrollToIndex),偶尔会产生双重触发导致动画抖动。
保守但稳的写法:给 cur一个守卫
activate(i: number) {
if (i === this.cur) return
this.cur = i
// 顺序不重要,但建议:先滚导航(用户视觉反馈最快),再切内容
this.navScroller.scrollToIndex(i, true, ScrollAlign.CENTER)
this.tabsCtrl.changeIndex(i)
}
六、你得到的"自顶到底"到底是什么
说清这句话就不迷了:
"自顶到底"不是Tabs的一个属性,而是布局约束链:
外层占满屏高度 → 内层Row/Column也100% → 左侧导航List也100%(scrollable)→ 条目从0开始排 → Scroller负责选中居中。
Tabs在这里的角色只剩一个:替你承载右侧多页内容,并提供 changeIndex / onChange的同步桥梁。一旦你接受"左侧栏=List,右侧=Tabs(无bar)",整个控件就从"Tabs边缘case"变成了"稳稳的Row两列布局"。
七、总结
购物比价/商城的一级分类页,左侧栏看起来简单,但它对顶对齐、行高精确、选中居中、安全区避让的要求其实很挑剔——这些要求恰好都不是Tabs页签条的强项。
所以最佳实践就一句话:
把导航还给List,把内容切换留给Tabs(barWidth=0藏掉),用Scroller.scrollToIndex做选中居中,把高度链从外层一路钉到'100%'。
这样做出来的分类页,不管条目是3个还是30个,都能做到:从顶开始排、撑满可滚、选中永远滚到视线正中——这才是商城用户直觉里的"侧边栏",而不是一个勉强滚动的页签盒。
更多推荐



所有评论(0)