吸顶效果,即页面滚动时让某一区域(如分类导航栏)固定在顶部,随页面滚动"贴"在标题栏下方,类似 Android/iOS 原生设计中的 StickyHeader。 本文结合实际生产页面 HomeExplorePage,深入解析 HarmonyOS ArkUI 中实现 Tabs 吸顶的完整方案。
| 交互 | 行为 |
|---|---|
| 页面初始 | 顶部 Banner 头图 + 下方绿色 TabBar 均可见 |
| 手指下滑 | WaterFlow 先滚动 → 列表到顶后外层 Scroll 继续 → TabBar 被推出屏幕 |
| 手指上滑 | 外层 Scroll 先滚回 → TabBar 贴到标题栏下方 → 列表继续滚动(吸顶完成) |
整体布局采用 外层 Scroll + 内层 Tabs + 子列表 的嵌套结构,与业界通用方案一致:
|
1 2 3 4 5 6 7 8 9 10 11 |
NavDestination └── Stack ← 根容器,提供 z 轴层级 ├── Stack 顶部标题栏 ← zIndex=2,始终在最前 │ height = statusBar + 标题高度 │ └── Scroll 外层滚动器 ← 【关键①】height='100%' └── Column ← 子内容回推总高度 → 产生滚动空间 ├── Banner Column ← 头图区,上滑滚出屏幕 └── Tabs 吸顶区域 ← 【关键②】calc(100% - avoidance) └── TabContent └── WaterFlow/List ← 【关键③nestedScroll + 关键④edgeEffect】 |
要实现流畅的吸顶效果,必须同时满足以下四个条件,缺一不可。
|
1 2 3 4 5 6 7 |
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%') | 高度基准偏小,列表内容填不满或溢出 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 避让高度 = 状态栏高度 + 标题栏高度 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) |
原理:
使用 calc(100% - avoidanceHeight) 后:
| 高度计算 | 值 |
|---|---|
| Scroll 总高 | 屏幕高度(100%) |
| 减去标题栏 | avoidanceHeight = statusBarHeight + 25 |
| Tabs 实际高度 | calc(100% - avoidanceHeight) = 屏幕高度 - 标题栏高度 |
效果: Tabs 精确填满 Scroll.Column 减去上方 Banner 和 hint 后的剩余垂直空间,列表内容不会溢出屏幕。
作用: tabBar 的高度由 Builder 内容自动撑开,而非固定数值。
在 tabBarBuilder 中,Row 高度为 44(tabs 文字行)+ Divider(1),如果使用固定数值如 barHeight(48):
barHeight('auto') 让 HarmonyOS 根据 tabBarBuilder() 实际渲染出的高度来计算 tabBar 区域,保证与 Builder 内容精确匹配。
附加配置:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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(向下滚动 / 手指上滑):
|
1 2 3 4 5 6 7 8 9 10 11 |
手指向上滑 → WaterFlow 响应滚动 ↓ WaterFlow 内容滚动,列表向上移动 ↓ WaterFlow 到达列表顶部(内容已无剩余) ↓ 滚动权交给外层 Scroll ↓ 外层 Scroll 继续向上滚动,Banner 和 TabBar 被推出可视区 ↓ TabBar "吸"在屏幕顶部(已滚出外层 Scroll 的可视区,紧贴标题栏) |
scrollBackward: SELF_FIRST(向上滚动 / 手指下滑):
|
1 2 3 4 5 6 7 8 9 10 11 |
手指向下滑 → WaterFlow 优先响应 ↓ WaterFlow 到达列表底部(内容已无剩余) ↓ WaterFlow 响应手指继续滚动(上拉手势) ↓ WaterFlow 到顶,滚动权交给外层 Scroll ↓ 外层 Scroll 向下滑动,TabBar 从标题栏下方落回 ↓ TabBar 重新出现在屏幕中 |
| 模式 | 值 | 行为 |
|---|---|---|
| scrollForward(下滑) | PARENT_FIRST | 列表先滚,到顶后交给外层继续 |
| scrollBackward(上滑) | SELF_FIRST | 外层先滚,TabBar 落位后列表再滚 |
如果子列表是 List,配置方式完全相同,将 WaterFlow 替换为 List 即可。
|
1 2 3 4 |
WaterFlow({ ... }) .nestedScroll({ ... }) // 【关键④】edgeEffect:禁用回弹 + 保持边缘感知,确保吸顶丝滑 .edgeEffect(EdgeEffect.None, { alwaysEnabled: true }) |
参数详解:
| 回弹效果 | 表现 | 吸顶场景下的影响 |
|---|---|---|
| Spring(默认) | 列表到顶/到底时,手指松开后内容弹回 | TabBar 在屏幕顶部抖动,无法稳定"吸住" |
| None | 列表到顶/到底时,无回弹动画 | 配合 nestedScroll,TabBar 平滑停在标题栏下方 |
alwaysEnabled: true — 保持边缘滚动感知(关键)
这是最容易遗漏的参数,作用如下:
|
1 2 3 4 5 |
没有 alwaysEnabled=true: WaterFlow 到顶 → 外层 Scroll 无法感知"已到顶" → 卡顿,无法交接滚动权
有 alwaysEnabled=true: WaterFlow 到顶 → 仍能感知边缘状态 → nestedScroll 正常触发 → 丝滑交接 |
常见误区: 如果只写 .edgeEffect(EdgeEffect.None) 而不设置 alwaysEnabled: true,会出现"卡顿"现象——列表到顶后外层 Scroll 短暂不响应,然后突然抢走滚动权,导致 TabBar 在顶部抖动。
综合效果:
以下为抽取核心逻辑后的最小可运行示例,基于 Demo 页面 :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
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) } } |
生产环境中的 与 Demo 逻辑完全一致,核心配置如下:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 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 先滚回再交还滚动权给列表。四个关键属性缺一,联动链条即断裂。