做购物商城/比价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)

关键点三个:

  1. 左侧导航栏用 List:天然支持滚动、条目复用、scrollToIndex、精确的item高度/间距/选中态

  2. 右侧用 TabsbarWidth(0)藏bar:你不需要它的bar了,导航切换由List的点击事件驱动(tabsController.changeIndex

  3. 选中项居中用 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)
  }
}

这版代码里你要盯紧的只有四句话

语句

为什么关键

List + .height('100%')

左侧栏要真的占满"从顶到底",前提就是外层Row也100%,否则'100%'是相对"内容"而不是"屏"

Tabs.barWidth(0)

告诉你:原bar我不用了,导航语义归List,Tabs退化成"内容切换器"

scrollToIndex(i, true, ScrollAlign.CENTER)

让选中项滚到可视区正中,而不是你用offset硬算

.layoutWeight(1)

右侧内容区自适应剩下的宽度(96固定 + 其余归右),否则你会看到"右侧没撑满,看起来不像整页"


四、为什么 Tabs 侧边栏看起来"应该行"但就是不顺

把Tabs侧边栏拆穿,你会更安心不用它当左侧栏:

  1. Tabs的bar不是List

    它更像"内部排版盒",你传的 TabContent的标题/自定义tabBar 最终被放进它的bar容器里,对齐方式由Tabs内部管理,你很难强制它"第一项永远贴顶 + 没撑满也贴顶"。

  2. barMode: Scrollable只解决"能不能滚",不解决"从哪里开始排"

    可滚≠顶部锚定;后者需要一个明确的首行偏移/对齐策略,而Tabs没把这个策略暴露成你想要的"分类树语义"。

  3. 选中居中更需要"外部滚动控制器"

    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个,都能做到:从顶开始排、撑满可滚、选中永远滚到视线正中——这才是商城用户直觉里的"侧边栏",而不是一个勉强滚动的页签盒。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐