HarmonyOS 鸿蒙吸顶效果的实现

HarmonyOS Tabs + WaterFlow 吸顶方案详解

吸顶效果,即页面滚动时让某一区域(如分类导航栏)固定在顶部,随页面滚动"贴"在标题栏下方,类似 Android/iOS 原生设计中的 StickyHeader。 本文结合实际生产页面 HomeExplorePage,深入解析 HarmonyOS ArkUI 中实现 Tabs 吸顶的完整方案。

先看结论

  • 吸顶是 Tabs + 父子滚动联动 共同实现的,不是单一属性生效。
  • 关键配置只有四个:height('100%')Tabs.height(calc(...))nestedScroll(...)edgeEffect(None, { alwaysEnabled: true })
  • 其中两个最容易遗漏:
    • .barHeight('auto'):让 tabBar 高度跟随内容
    • alwaysEnabled: true:边界处持续感知,避免"卡一下"

阅读导航

  1. 想快速落地:看「四个关键属性」+「方案总结」
  2. 想理解原理:看「关键③ nestedScroll」和「关键④ edgeEffect」
  3. 想直接复制:看「完整代码示例」

一、效果演示

交互 行为
页面初始 顶部 Banner 头图 + 下方绿色 TabBar 均可见
手指下滑 WaterFlow 先滚动 → 列表到顶后外层 Scroll 继续 → TabBar 被推出屏幕
手指上滑 外层 Scroll 先滚回 → TabBar 贴到标题栏下方 → 列表继续滚动(吸顶完成)

二、布局结构

整体布局采用 外层 Scroll + 内层 Tabs + 子列表 的嵌套结构,与业界通用方案一致:

ini 复制代码
NavDestination
└── Stack                                    ← 根容器,提供 z 轴层级
    ├── Stack  顶部标题栏                     ← zIndex=2,始终在最前
    │       height = statusBar + 标题高度
    │
    └── Scroll  外层滚动器                     ← 【关键①】height='100%'
        └── Column                            ← 子内容回推总高度 → 产生滚动空间
            ├── Banner Column                 ← 头图区,上滑滚出屏幕
            └── Tabs  吸顶区域                 ← 【关键②】calc(100% - avoidance)
                └── TabContent
                    └── WaterFlow/List        ← 【关键③nestedScroll + 关键④edgeEffect】

三、四个关键属性(重点)

要实现流畅的吸顶效果,必须同时满足以下四个条件,缺一不可。

关键① ------ 外层滚动器:height='100%'

ets 复制代码
Scroll(this.outerScroller) {
  Column() { /* 子内容 */ }
  .width('100%')
}
.width('100%')
.height('100%')   // ← 关键:不约束子 Column 高度,由内容回推总高度 → 产生滚动空间
.scrollBar(BarState.Off)

作用: height('100%') 表示 Scroll 的高度基准等于父容器,不对子内容做高度截断。子 Column 的总高度由内部所有子组件回推得到,当内容超出屏幕时自然产生滚动区域。

常见误区:

错误写法 问题
内层 Stack 设置 height('100%') 锁死高度,子 Column 无法撑开,Scroll 无滚动空间
constraintSize({ minHeight: '100%' }) 同样约束高度,效果等同于上例
Tabs 使用 layoutWeight(1) + height('100%') 高度基准偏小,列表内容填不满或溢出

关键② ------ Tabs 高度与 barHeight

ets 复制代码
// 避让高度 = 状态栏高度 + 标题栏高度
private getAvoidanceHeight(): number {
  return WindowHelper.statusBarHeight + 25
}

Tabs({ index: $$this.selectedTabIndex }) {
  TabContent() { this.tabContentBuilder() }
    .tabBar(this.tabBarBuilder())
}
// 【关键②-a】Tabs 高度固定为 Scroll.Column 的剩余空间
.height(`calc(100% - ${this.getAvoidanceHeight()}vp)`)
// 【关键②-b】tabBar 高度由内容撑开
.barHeight('auto')
.scrollable(false)            // 禁用 Tabs 内置滚动,由子列表承载
.barPosition(BarPosition.Start) // tabBar 在内容上方(纵向吸顶)
.clip(true)
关键②-a:height = calc(100% - avoidanceHeight)

原理:

  • 外层 Scroll.Column 布局:顶部 Banner(180) + hint 提示 + Tabs
  • Scroll 设定 height='100%'(关键①),不约束自身高度,由子元素回推总高
  • 此时 Column 总高度 = Banner(180) + hint + Tabs
  • 如果 Tabs 不设高度,会被内容撑开,可能把 Tabs 自身的一部分内容顶出屏幕底部

使用 calc(100% - avoidanceHeight) 后:

高度计算
Scroll 总高 屏幕高度(100%)
减去标题栏 avoidanceHeight = statusBarHeight + 25
Tabs 实际高度 calc(100% - avoidanceHeight) = 屏幕高度 - 标题栏高度

效果: Tabs 精确填满 Scroll.Column 减去上方 Banner 和 hint 后的剩余垂直空间,列表内容不会溢出屏幕。

关键②-b:barHeight('auto')

作用: tabBar 的高度由 Builder 内容自动撑开,而非固定数值。

tabBarBuilder 中,Row 高度为 44(tabs 文字行)+ Divider(1),如果使用固定数值如 barHeight(48)

  • tabBar 内容变化(如增加一行文字)时,高度不会自适应
  • 固定数值与 Builder 实际高度不匹配时,会产生裁剪或留白

barHeight('auto') 让 HarmonyOS 根据 tabBarBuilder() 实际渲染出的高度来计算 tabBar 区域,保证与 Builder 内容精确匹配。

附加配置:

  • scrollable(false):禁用 Tabs 内置滑动切换,内容滚动完全由 WaterFlow 承载,避免两层滚动互相干扰
  • barPosition(BarPosition.Start):tabBar 位于内容上方(纵向),横向 Tabs 吸顶用 End
  • clip(true):裁剪超出区域,防止 Tabs 内容越界

关键③ ------ nestedScroll:父子滚动联动

ets 复制代码
WaterFlow({
  scroller: this.waterFlowScroller,
  footer: this.footerBuilder()
}) {
  LazyForEach(this.dataSource, (item: number) => {
    FlowItem() { this.waterFlowItem(item) }
  }, (item: number) => item.toString())
}
// 【关键③】nestedScroll:协调父子滚动容器之间的优先级
.nestedScroll({
  scrollForward: NestedScrollMode.PARENT_FIRST,  // 向下滚动:列表先滚,到顶后外层 Scroll 接管
  scrollBackward: NestedScrollMode.SELF_FIRST    // 向上滚动:列表先滚回,再由外层 Scroll 接手
})

核心作用: 决定父子两个滚动容器"谁先响应滚动"。

scrollForward: PARENT_FIRST(向下滚动 / 手指上滑):

sql 复制代码
手指向上滑 → WaterFlow 响应滚动
    ↓
WaterFlow 内容滚动,列表向上移动
    ↓
WaterFlow 到达列表顶部(内容已无剩余)
    ↓
滚动权交给外层 Scroll
    ↓
外层 Scroll 继续向上滚动,Banner 和 TabBar 被推出可视区
    ↓
TabBar "吸"在屏幕顶部(已滚出外层 Scroll 的可视区,紧贴标题栏)

scrollBackward: SELF_FIRST(向上滚动 / 手指下滑):

scss 复制代码
手指向下滑 → WaterFlow 优先响应
    ↓
WaterFlow 到达列表底部(内容已无剩余)
    ↓
WaterFlow 响应手指继续滚动(上拉手势)
    ↓
WaterFlow 到顶,滚动权交给外层 Scroll
    ↓
外层 Scroll 向下滑动,TabBar 从标题栏下方落回
    ↓
TabBar 重新出现在屏幕中
模式 行为
scrollForward(下滑) PARENT_FIRST 列表先滚,到顶后交给外层继续
scrollBackward(上滑) SELF_FIRST 外层先滚,TabBar 落位后列表再滚

如果子列表是 List,配置方式完全相同,将 WaterFlow 替换为 List 即可。


关键④ ------ edgeEffect:吸顶边界处理

ets 复制代码
WaterFlow({ ... })
.nestedScroll({ ... })
// 【关键④】edgeEffect:禁用回弹 + 保持边缘感知,确保吸顶丝滑
.edgeEffect(EdgeEffect.None, { alwaysEnabled: true })

参数详解:

EdgeEffect.None --- 禁用弹性回弹
回弹效果 表现 吸顶场景下的影响
Spring(默认) 列表到顶/到底时,手指松开后内容弹回 TabBar 在屏幕顶部抖动,无法稳定"吸住"
None 列表到顶/到底时,无回弹动画 配合 nestedScroll,TabBar 平滑停在标题栏下方

alwaysEnabled: true --- 保持边缘滚动感知(关键)

这是最容易遗漏的参数,作用如下:

ini 复制代码
没有 alwaysEnabled=true:
  WaterFlow 到顶 → 外层 Scroll 无法感知"已到顶" → 卡顿,无法交接滚动权

有 alwaysEnabled=true:
  WaterFlow 到顶 → 仍能感知边缘状态 → nestedScroll 正常触发 → 丝滑交接

常见误区: 如果只写 .edgeEffect(EdgeEffect.None) 而不设置 alwaysEnabled: true,会出现"卡顿"现象------列表到顶后外层 Scroll 短暂不响应,然后突然抢走滚动权,导致 TabBar 在顶部抖动。

综合效果:

  • 子列表到顶时,不触发回弹动画
  • 吸顶边界时,alwaysEnabled: true 保证滚动感知不断联
  • 配合 nestedScroll 实现"列表到顶 → TabBar 丝滑吸住"的体验

四、完整代码示例

以下为抽取核心逻辑后的最小可运行示例,基于 Demo 页面 WaterFlowStickyDemoPage

ets 复制代码
import { WindowHelper } from '@zebra/foundation/src/main/ets/utils/WindowHelper'

@ComponentV2
export struct WaterFlowStickyDemoPage {
  // ── 滚动器 ──────────────────────────────────────────────────
  private outerScroller: Scroller = new Scroller()     // 【关键①】外层滚动
  private waterFlowScroller: Scroller = new Scroller() // 【关键③】子列表滚动

  // ── 避让高度 ──────────────────────────────────────────────
  //  = 状态栏高度 + 标题栏高度
  private getAvoidanceHeight(): number {
    return WindowHelper.statusBarHeight + 25
  }

  // ── TabBar ──────────────────────────────────────────────────
  @Builder
  tabBarBuilder() {
    Column() {
      Row() {
        ForEach(['推荐', '热门', '最新'], (tab: string, index: number) => {
          Column() {
            Text(tab)
              .fontSize(15)
              .fontColor(this.selectedTabIndex === index ? '#00CC66' : '#999999')
            Divider()
              .width(this.selectedTabIndex === index ? 20 : 0)
              .height(2)
              .backgroundColor('#00CC66')
              .margin({ top: 4 })
          }
          .width(60)
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .height(44)
      Divider().width('100%').strokeWidth(1).color('#C8E6C9')
    }
    .width('100%')
    .backgroundColor('#E8F5E9')
  }

  // ── TabContent ─────────────────────────────────────────────
  @Builder
  tabContentBuilder() {
    WaterFlow({
      scroller: this.waterFlowScroller
    }) {
      LazyForEach(this.dataSource, (item: number) => {
        FlowItem() {
          Column() {
            Text(`${item}`)
          }
          .width('100%')
          .height(88 + (item % 7) * 28)
          .backgroundColor(colors[item % colors.length])
          .borderRadius(8)
        }
      }, (item: number) => item.toString())
    }
    .columnsTemplate('1fr 1fr')
    .columnsGap(12)
    .rowsGap(12)
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .scrollBar(BarState.Off)
    // 【关键③】nestedScroll
    .nestedScroll({
      scrollForward: NestedScrollMode.PARENT_FIRST,
      scrollBackward: NestedScrollMode.SELF_FIRST
    })
    // 【关键④】edgeEffect
    .edgeEffect(EdgeEffect.None, { alwaysEnabled: true })
  }

  // ── Tabs 主体 ───────────────────────────────────────────────
  @Builder
  contentBuilder() {
    Tabs({ index: $$this.selectedTabIndex }) {
      TabContent() { this.tabContentBuilder() }
        .tabBar(this.tabBarBuilder())
    }
    // 【关键②-a】Tabs 高度 = Scroll.Column 剩余空间
    .height(`calc(100% - ${this.getAvoidanceHeight()}vp)`)
    // 【关键②-b】tabBar 高度由 Builder 内容撑开
    .barHeight('auto')
    .scrollable(false)
    .barPosition(BarPosition.Start)
    .clip(true)
  }

  // ── build ───────────────────────────────────────────────────
  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Top }) {
        // 顶部标题栏
        Column() {
          Text('页面标题')
            .fontSize(17)
            .height(25)
        }
        .width('100%')
        .height(WindowHelper.statusBarHeight + 25)
        .padding({ left: 16 })
        .zIndex(2)

        // 外层 Scroll 【关键①】
        Scroll(this.outerScroller) {
          Column() {
            // Banner 头图区
            Column() {
              Text('Banner Area')
            }
            .width('100%')
            .height(180)
            .backgroundColor('#E8F5E9')

            // Tabs 吸顶区域 【关键②③④】
            this.contentBuilder()
          }
          .width('100%')
        }
        .width('100%')
        .height('100%')   // ← 关键①:height='100%',内容回推高度
        .scrollBar(BarState.Off)
      }
      .width('100%')
      .height('100%')
    }
    .hideTitleBar(true)
  }
}

五、实战:HomeExplorePage 中的吸顶实现

生产环境中的 HomeExplorePage 与 Demo 逻辑完全一致,核心配置如下:

ets 复制代码
// HomeExplorePage.ets

// ① 外层滚动器 height='100%'
Scroll(this.exploreScroller) {
  Column() {
    // Banner + Tabs
    Tabs()
    // 【关键②-a】Tabs 高度 = Scroll.Column 剩余空间
    .height(`calc(100% - ${this.explorePageDataModel.avoidanceHeight}vp)`)
    // 【关键②-b】tabBar 高度由 Builder 内容撑开
    .barHeight('auto')
    .scrollable(false)
    .barPosition(BarPosition.Start)
    .clip(true)
  }
}
.width('100%')
.height('100%')   // ← 关键①

// ② WaterFlow nestedScroll + edgeEffect
WaterFlow({ scroller: this.waterFlowScroller }) {
  LazyForEach(...)
}
// 【关键③】nestedScroll
.nestedScroll({
  scrollForward: NestedScrollMode.PARENT_FIRST,
  scrollBackward: NestedScrollMode.SELF_FIRST
})
// 【关键④】edgeEffect
.edgeEffect(EdgeEffect.None, { alwaysEnabled: true })

六、方案总结

关键属性 配置 作用
① 外层 Scroll height('100%') 由子内容回推总高度,产生滚动区域
②-a Tabs 高度 calc(100% - avoidanceHeight) 固定占据 Scroll.Column 剩余空间,基准与 Scroll 一致
②-b barHeight barHeight('auto') tabBar 高度由 Builder 内容撑开,避免固定数值裁剪
③ nestedScroll PARENT_FIRST + SELF_FIRST 列表与外层滚动器平滑联动,实现 TabBar 吸顶
④ edgeEffect EdgeEffect.None, { alwaysEnabled: true } 吸顶边界时不触发回弹,保持 TabBar 稳定

核心心法: 吸顶的本质是 父子滚动器的嵌套联动 --- 子列表到顶后让出滚动权给外层 Scroll,上滑时外层 Scroll 先滚回再交还滚动权给列表。四个关键属性缺一,联动链条即断裂。

相关推荐
讯方洋哥4 小时前
HarmonyOS App开发——鸿蒙ArkTS端云一体化云数据库应用和实战
数据库·harmonyos
互联网散修4 小时前
鸿蒙应用开发UI基础第三十节:循环渲染核心ForEach 实战与性能优化
ui·华为·harmonyos
UnicornDev7 小时前
【HarmonyOS 6】活动标签管理页面实现
华为·harmonyos·arkts·鸿蒙·鸿蒙系统
小雨青年7 小时前
鸿蒙 HarmonyOS 6 | 文件系统 沙箱机制与权限拒绝
华为·harmonyos
大雷神8 小时前
HarmonyOS APP<玩转React>开源教程二十一:测验服务层实现
前端·react.js·开源·harmonyos
qq_283720058 小时前
Qt QML 中为 ComBox设置鸿蒙字体(HarmonyOS Sans)——适配 Qt 5.6.x 与 Qt 5.12+
c++·qt·harmonyos
花先锋队长9 小时前
华为音乐世界睡眠日特别策划上线,在沉浸式空间音频摇篮曲中入梦
华为·智能手机·harmonyos
卡兰芙的微笑10 小时前
对鸿蒙蓝牙接口进行xts用例编写
华为·harmonyos
ShuiShenHuoLe10 小时前
管理数据的状态
harmonyos·鸿蒙