HarmonyOS 悬浮页签布局边界

悬浮页签最容易被低估。很多页面一开始只是想让分类切换更顺手,于是把 Tabs 做成悬浮样式,放在标题下面或者图片头图上方。视觉上确实更轻,但布局边界如果没算清楚,后面就会出现遮挡。

这篇按 HarmonyOS 5.0.0 及以上版本的 ArkUI 页面适配来写,重点放在悬浮页签、safeArea、滚动吸顶和折叠屏断点。它不是某一个页面的样式技巧,而是一个布局计算问题。

我会先看三个现象:

  • 页面顶部做了沉浸式,页签顶到状态栏下面,标题被压住;
  • 列表第一行刚好藏在悬浮页签后面;
  • 折叠屏展开后,页签还是按手机宽度计算,内容区错位。

这些问题不是靠多加几个 marginTop 就能彻底解决。真正要拆的是:安全区、页签高度、滚动偏移、断点布局分别由谁负责。

先把悬浮页签当成布局占位

悬浮不等于脱离布局。只要它会盖住内容,就必须进入布局计算。我的拆法是:

模块 负责什么 不负责什么
SafeArea 层 接住状态栏、导航栏和设备安全区 不写具体业务间距
FloatingTabBar 只负责页签高度、选中态、点击切换 不直接控制列表滚动
ContentOffset 计算列表顶部预留高度 不保存页签业务状态
Breakpoint 根据宽度决定单列、双列、页签宽度 不靠设备名猜布局

拆完之后,悬浮页签就不是一个随手写的 position 效果,而是页面布局的一部分。

错误案例:页签绝对定位,但内容没有让位

下面这种写法很常见:

Stack() {
  List() {
    ForEach(this.items, (item) => {
      ListItem() {
        Text(item.title)
      }
    })
  }

  Tabs() {
    TabContent() { Text('推荐') }
    TabContent() { Text('收藏') }
  }
  .height(48)
  .position({ x: 0, y: 0 })
}

这段代码的问题是:Tabs 浮起来了,但 List 并不知道顶部被占了 48vp。页面一滚动,第一行内容就可能被盖住。沉浸式页面里再叠加状态栏高度,问题会更明显。

案例一:手机竖屏,顶部安全区 + 页签高度一起算

先把顶部占位算出来:

interface FloatingTabLayout {
  safeTop: number
  tabHeight: number
  stickyTop: number
  listPaddingTop: number
}

function buildFloatingTabLayout(safeTop: number, tabHeight: number): FloatingTabLayout {
  return {
    safeTop,
    tabHeight,
    stickyTop: safeTop,
    listPaddingTop: safeTop + tabHeight
  }
}

页面里使用时,列表顶部要让出同样高度:

@State layout: FloatingTabLayout = buildFloatingTabLayout(24, 48)

build() {
  Stack() {
    List() {
      ForEach(this.items, (item) => {
        ListItem() {
          Text(item.title)
        }
      })
    }
    .padding({ top: this.layout.listPaddingTop })

    Row() {
      Text('推荐')
      Text('收藏')
      Text('最近')
    }
    .height(this.layout.tabHeight)
    .position({ x: 0, y: this.layout.stickyTop })
  }
}

这段逻辑的关键是:页签显示在哪里,列表就让出多少空间。不要只让页签浮起来,却不告诉内容区。

案例二:折叠屏展开后,页签宽度和吸顶位置要重算

折叠屏或平板上,问题不只是宽度变了。很多页面会从单列变成双列,页签也可能从全宽变成左侧内容区宽度。如果仍然按手机布局写死,就会出现页签跨过详情区、列表内容被错误遮挡。

可以先定义断点规则:

type Breakpoint = 'sm' | 'md' | 'lg'

function resolveBreakpoint(width: number): Breakpoint {
  if (width >= 960) return 'lg'
  if (width >= 600) return 'md'
  return 'sm'
}

function buildTabMetrics(width: number, safeTop: number) {
  const bp = resolveBreakpoint(width)
  const tabHeight = bp === 'sm' ? 48 : 52
  const contentWidth = bp === 'lg' ? Math.floor(width * 0.42) : width
  return {
    breakpoint: bp,
    tabHeight,
    tabWidth: contentWidth,
    stickyTop: safeTop,
    listPaddingTop: safeTop + tabHeight
  }
}

当窗口宽度变化时,重新计算页签指标:

@State tabMetrics = buildTabMetrics(390, 24)

onWindowSizeChange(width: number, safeTop: number): void {
  this.tabMetrics = buildTabMetrics(width, safeTop)
}

这样折叠屏展开后,页签只覆盖列表区,不会横跨详情区。列表顶部也能根据新高度重新让位。

几种方案怎么选

方案 适合场景 风险
写死 marginTop 临时页面、固定设备 状态栏、折叠屏、横竖屏一变就错
Stack 绝对定位 只做视觉悬浮 内容区容易被遮挡
页签高度进入内容 padding 长列表、沉浸式页面 需要统一维护布局指标
断点驱动页签宽度和高度 折叠屏、平板、鸿蒙电脑 代码多一点,但适配更稳

我会优先选第三种和第四种组合:页签可以悬浮,但它的高度必须进入内容区计算;设备宽度变化时,页签宽度和吸顶位置必须一起更新。

可以封装成布局指标

页面多了以后,不要到处写 safeTop + 48。可以沉淀成一个小工具:

export class FloatingTabMetrics {
  static build(width: number, safeTop: number) {
    const breakpoint = width >= 960 ? 'lg' : width >= 600 ? 'md' : 'sm'
    const tabHeight = breakpoint === 'sm' ? 48 : 52
    return {
      breakpoint,
      tabHeight,
      stickyTop: safeTop,
      listPaddingTop: safeTop + tabHeight,
      tabWidth: breakpoint === 'lg' ? Math.floor(width * 0.42) : width
    }
  }
}

页面只消费结果,不关心计算细节:

const metrics = FloatingTabMetrics.build(windowWidth, safeTop)

这类封装能减少很多视觉修补。以后换页签高度、改断点、加鸿蒙电脑适配,只改一处。

吸顶状态不要反向改列表滚动

还有一个容易出问题的点:页签进入吸顶状态以后,不要反过来频繁改列表滚动位置。页签应该根据滚动位置决定自己显示在哪里,而不是每次页签状态变化又去推动列表滚动。

我一般只保留一个滚动观察值:

interface StickyState {
  scrollY: number
  sticky: boolean
}

function resolveSticky(scrollY: number, threshold: number): StickyState {
  return {
    scrollY,
    sticky: scrollY >= threshold
  }
}

页面滚动时只更新页签展示状态:

onScrollOffsetChange(offset: number): void {
  this.stickyState = resolveSticky(offset, this.headerHeight)
}

这样做可以避免一种抖动:列表刚滚到临界点,页签变成吸顶;吸顶以后又修改列表偏移,列表又退回临界点之前,页签又取消吸顶。这个循环肉眼看起来就是顶部栏轻微跳动。

如果页面有头图、搜索框、悬浮页签三层结构,我会把阈值写清楚:

const stickyThreshold = safeTop + heroHeight - tabHeight

阈值只由布局尺寸决定,不要夹杂当前分类、列表数据量、网络请求状态。分类切换只改变内容,不改变吸顶边界。这个边界稳定以后,页面切分类、切横竖屏、展开折叠屏时都更容易排查。

本地验证结果

我用脚本验证了两个场景:

{
  "phone": {
    "breakpoint": "sm",
    "tabHeight": 48,
    "listPaddingTop": 72,
    "contentCovered": false
  },
  "foldable": {
    "breakpoint": "lg",
    "tabHeight": 52,
    "tabWidth": 430,
    "contentCovered": false
  }
}

第一组证明手机竖屏下安全区和页签高度都进入了列表顶部预留;第二组证明折叠屏展开后,页签宽度不会错误覆盖右侧详情区。

排查时先看这几个点

悬浮页签遮挡内容时,我会按这个顺序查:

  • 页签高度有没有进入内容区顶部 padding;
  • 沉浸式页面有没有把 safeArea 算进去;
  • 横竖屏和折叠屏展开后,有没有重新计算断点;
  • 页签宽度是不是只覆盖列表区,而不是覆盖整个窗口;
  • 滚动吸顶位置是不是和安全区保持一致。

悬浮页签不是不能做,关键是不能只做视觉浮层。只要它会盖住内容,它就必须参与布局计算。把 safeArea、页签高度、列表预留和断点宽度拆清楚,后面再做沉浸式、折叠屏、平板和鸿蒙电脑适配,才不会越补越乱。

Logo

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

更多推荐