吸顶效果与粘性头部——从原生实现到自定义手势联动
文章目录

每日一句正能量
“只要热情、努力、乐观,我们终将成为自己的太阳,无需凭借谁的光。”
热情是燃料,努力是燃烧,乐观是持续燃烧的姿态。成为自己的太阳,意味着不再需要从他处借光取暖——你本身就在发光,哪怕微弱,也是自己的、不熄灭的光。
愿你温柔而坚定地,做自己的太阳,也做别人的一缕晨光。
摘要
在移动端应用开发中,吸顶效果(Sticky Header) 是提升长列表浏览体验的核心交互模式之一。当用户向上滚动页面时,分组头部或导航栏固定在视口顶部,既保证了上下文信息的持续可见,又避免了频繁回滚的繁琐操作。HarmonyOS ArkTS 提供了从原生声明式到自定义布局的多层次吸顶实现方案,覆盖从简单分组列表到复杂电商详情页的全场景需求。
本文基于 HarmonyOS 6(API 23),系统梳理三种吸顶实现路径:ListItemGroup 原生吸顶、Scroll + Stack 自定义吸顶,以及 Tabs + nestedScroll 嵌套联动吸顶。同时,结合前序文章「自定义手势识别器」的技术积累,深入探讨手势事件与吸顶状态的双向联动机制,为开发者提供一套完整的、可落地的吸顶效果工程化方案。
一、吸顶效果的核心原理
吸顶效果的本质,是滚动容器与固定元素之间的位置博弈。当滚动偏移量(scroll offset)超过预设阈值时,目标元素脱离正常文档流,以绝对定位或固定定位的方式吸附在视口边界;当滚动回退时,元素恢复原始布局位置。

在 HarmonyOS ArkTS 中,吸顶效果的实现依赖于以下核心机制:
| 机制 | 作用 | 适用组件 |
|---|---|---|
sticky 属性 |
声明式开启分组头部/尾部的吸附能力 | List |
onWillScroll / onScroll |
实时监听滚动偏移量,驱动自定义吸顶逻辑 | Scroll、List、WaterFlow |
nestedScroll |
协调父子滚动容器的响应优先级 | Scroll 嵌套 Tabs/WaterFlow |
Scroller 控制器 |
程序化控制滚动位置,实现精准跳转 | 所有滚动容器 |
从 API 演进来看,HarmonyOS 在 API 20 之前仅支持 StickyStyle.Header 和 StickyStyle.Footer 的或运算组合;从 API 20 起新增 StickyStyle.BOTH,可一键同时开启吸顶与吸底,进一步简化了分组列表的配置成本。
二、方案一:ListItemGroup 原生吸顶(极简方案)
对于标准的分组列表场景(如通讯录、课程表、订单分类),HarmonyOS 提供了零成本的原生吸顶能力。开发者只需三步即可完成:定义分组数据源 → 配置 ListItemGroup 的 header/footer → 为 List 设置 sticky 属性。
2.1 数据层:标准 IDataSource 实现
// 分组数据模型
export interface GroupModel {
title: string; // 分组头部标题
items: string[]; // 分组子项数据
}
// 分组数据源(适配 LazyForEach 懒加载)
export class GroupDataSource implements IDataSource {
private list: GroupModel[] = [];
private listeners: DataChangeListener[] = [];
constructor(list: GroupModel[]) {
this.list = list;
}
totalCount(): number { return this.list.length; }
getData(index: number): GroupModel { return this.list[index]; }
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) this.listeners.splice(pos, 1);
}
}
// 子项数据源
export class ItemDataSource implements IDataSource {
private list: string[] = [];
constructor(list: string[]) { this.list = list; }
totalCount(): number { return this.list.length; }
getData(index: number): string { return this.list[index]; }
registerDataChangeListener() {}
unregisterDataChangeListener() {}
}
2.2 UI 层:吸顶 + 吸底完整实现
@Entry
@Component
struct StickyListPage {
@State groupData: GroupDataSource = new GroupDataSource([]);
aboutToAppear(): void {
const data: GroupModel[] = [
{ title: '今日课程', items: ['高等数学', '大学英语', '线性代数'] },
{ title: '明日课程', items: ['数据结构', '操作系统', '计算机网络'] },
{ title: '本周实验', items: ['算法实验', '网络实验'] },
{ title: '考试安排', items: ['期中考试', '期末考试'] }
];
this.groupData = new GroupDataSource(data);
}
@Builder
groupHeader(title: string) {
Row() {
Text(title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Blank()
Text('展开')
.fontSize(12)
.fontColor('#FFFFFF')
.opacity(0.8)
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor('#4A90D9')
}
@Builder
groupFooter(count: number) {
Row() {
Text(`共 ${count} 项`)
.fontSize(12)
.fontColor('#666666')
}
.width('100%')
.height(36)
.padding({ left: 16 })
.backgroundColor('#F5F5F5')
}
build() {
Column() {
List({ space: 0 }) {
LazyForEach(this.groupData, (group: GroupModel) => {
ListItemGroup({
header: this.groupHeader(group.title),
footer: this.groupFooter(group.items.length)
}) {
LazyForEach(new ItemDataSource(group.items), (item: string) => {
ListItem() {
Row() {
Text(item)
.fontSize(16)
.fontColor('#333333')
Blank()
Image($r('app.media.ic_arrow_right'))
.width(16)
.height(16)
.fillColor('#CCCCCC')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
}
}, item => item)
}
.divider({ strokeWidth: 0.5, color: '#E8E8E8' })
})
}
.width('100%')
.layoutWeight(1)
.sticky(StickyStyle.Header | StickyStyle.Footer) // 同时开启吸顶+吸底
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
.backgroundColor('#F0F0F0')
}
}
2.3 方案一总结
| 特性 | 说明 |
|---|---|
| 代码量 | 极少(约 10 行核心配置) |
| 性能 | 框架原生优化,无需手动计算偏移 |
| 适用场景 | 通讯录、课程表、商品分类等标准分组列表 |
| 局限性 | 仅适用于 List 容器,无法支持非列表组件的吸顶 |
三、方案二:Scroll + Stack 自定义吸顶(灵活方案)
当吸顶目标不是 ListItemGroup 的头部,而是页面中的任意组件(如搜索栏、筛选条件栏)时,需要借助 Scroll + Stack 的组合,通过手动监听滚动偏移量实现动态吸顶。
3.1 实现原理
采用 Stack 层叠布局:底层为 Scroll 滚动容器(包含占位组件 + 长内容),上层为吸顶目标组件。通过 onWillScroll 实时获取滚动偏移量 currYOffset,动态计算吸顶组件的 margin.top,使其在滚动超过阈值时「贴」到顶部。
@Entry
@Component
struct CustomStickyPage {
// 布局参数
private readonly bannerHeight: number = 180; // Banner高度
private readonly stickyHeight: number = 48; // 吸顶组件高度
private readonly contentHeight: number = 1200; // 模拟内容高度
// 状态与控制器
@State currYOffset: number = 0;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.scroller.scrollTo({ xOffset: 0, yOffset: 0 });
}
build() {
Column() {
// 顶部固定标题栏(不参与滚动)
Row() {
Text('自定义吸顶示例')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width('100%')
.height(56)
.padding({ left: 16 })
.backgroundColor('#2C3E50')
Stack({ alignContent: Alignment.Top }) {
// 底层:Scroll 滚动容器
Scroll(this.scroller) {
Column() {
// Banner 区域(滚动时会被推出视口)
Column() {
Text('Banner 广告位')
.fontSize(20)
.fontColor('#FFFFFF')
}
.width('100%')
.height(this.bannerHeight)
.backgroundColor('#3498DB')
.justifyContent(FlexAlign.Center)
// 吸顶组件占位区(关键!保持滚动高度一致)
Column()
.width('100%')
.height(this.stickyHeight)
// 长内容区域
Column() {
ForEach(Array.from({ length: 30 }, (_, i) => i), (index: number) => {
Row() {
Text(`列表项 ${index + 1}`)
.fontSize(14)
.fontColor('#333333')
Blank()
Text('详情 >')
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.height(50)
.padding({ left: 16, right: 16 })
.backgroundColor(index % 2 === 0 ? '#FFFFFF' : '#FAFAFA')
})
}
.width('100%')
}
.width('100%')
}
.width('100%')
.height('100%')
.scrollable(ScrollDirection.Vertical)
.edgeEffect(EdgeEffect.Spring)
.onWillScroll((xOffset: number, yOffset: number) => {
// 累加滚动偏移量(注意:yOffset 为单次增量)
this.currYOffset += yOffset;
// 边界保护:防止快速滑动导致偏移量越界
this.currYOffset = Math.max(0, Math.min(this.currYOffset,
this.bannerHeight + this.contentHeight));
})
// 上层:吸顶筛选栏(动态 margin 实现吸顶)
Column() {
Row() {
Text('综合排序')
.fontSize(14)
.fontColor('#333333')
Image($r('app.media.ic_arrow_down'))
.width(12)
.height(12)
.fillColor('#666666')
Blank()
Text('销量优先')
.fontSize(14)
.fontColor('#666666')
Blank()
Text('价格筛选')
.fontSize(14)
.fontColor('#666666')
}
.width('100%')
.height(this.stickyHeight)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
.shadow({ radius: 2, color: '#000000', offsetY: 1, opacity: 0.1 })
}
.width('100%')
.margin({
top: this.bannerHeight - Math.min(this.currYOffset, this.bannerHeight)
})
// 吸顶时增加阴影,提升视觉层级
.shadow(this.currYOffset >= this.bannerHeight ?
{ radius: 4, color: '#000000', offsetY: 2, opacity: 0.15 } :
{ radius: 0, color: '#000000', offsetY: 0, opacity: 0 })
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F0F0F0')
}
}
3.2 关键设计要点
- 占位组件不可省略:
Scroll内部必须预留与吸顶组件等高的占位区域,否则滚动高度计算会出现偏差,导致内容跳动。 - 偏移量边界保护:
onWillScroll返回的是单次滚动增量,需累加并限制在合理范围内,防止边界回弹时偏移量异常。 - 视觉反馈:吸顶瞬间增加阴影(
shadow),可显著提升用户的感知度,这是许多开发者容易忽略的细节。
四、方案三:Tabs + nestedScroll 嵌套联动吸顶(电商标配)
在电商详情页、内容社区等复杂场景中,吸顶效果往往与 Tab 切换 深度绑定:页面顶部有 Banner 头图,下方是分类 Tab 栏,Tab 栏滚动到顶部时吸住,继续滚动则切换 Tab 内容。这种「外层 Scroll + 内层 Tabs + 子列表」的三层嵌套结构,是吸顶效果中最具工程挑战的方案。

4.1 四个关键属性(缺一不可)
| 关键属性 | 配置值 | 作用 |
|---|---|---|
| ① 外层 Scroll 高度 | height('100%') |
不截断子内容,由内容回推总高度,产生滚动空间 |
| ② Tabs 高度 | calc(100% - avoidanceHeight) |
精确填满剩余空间,避免内容溢出或留白 |
③ nestedScroll |
PARENT_FIRST + SELF_FIRST |
协调父子滚动优先级,实现吸顶丝滑过渡 |
④ edgeEffect |
EdgeEffect.None, { alwaysEnabled: true } |
禁用回弹 + 保持边缘感知,防止吸顶抖动 |

4.2 完整实现代码
import { window } from '@kit.ArkUI';
@Entry
@Component
struct TabsStickyPage {
// 滚动控制器
private outerScroller: Scroller = new Scroller();
private waterFlowScroller: Scroller = new Scroller();
// 状态
@State selectedTabIndex: number = 0;
@State statusBarHeight: number = 0;
aboutToAppear(): void {
// 获取状态栏高度(用于计算避让区域)
window.getLastWindow(getContext()).then((win) => {
this.statusBarHeight = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height;
});
}
// 计算避让高度 = 状态栏 + 自定义标题栏
private getAvoidanceHeight(): number {
return this.statusBarHeight + 48; // 48vp 为标题栏高度
}
@Builder
tabBarBuilder() {
Column() {
Row() {
ForEach(['推荐', '热门', '最新', '关注'], (tab: string, index: number) => {
Column() {
Text(tab)
.fontSize(15)
.fontWeight(this.selectedTabIndex === index ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.selectedTabIndex === index ? '#00CC66' : '#999999')
Divider()
.width(this.selectedTabIndex === index ? 20 : 0)
.height(3)
.backgroundColor('#00CC66')
.margin({ top: 4 })
.animation({ duration: 200, curve: Curve.EaseInOut })
}
.width(60)
.onClick(() => {
this.selectedTabIndex = index;
})
})
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.SpaceEvenly)
Divider()
.width('100%')
.height(1)
.color('#E8E8E8')
}
.width('100%')
.backgroundColor('#FFFFFF')
}
@Builder
waterFlowContent() {
WaterFlow({ scroller: this.waterFlowScroller }) {
LazyForEach(this.mockDataSource(), (item: number) => {
FlowItem() {
Column() {
Text(`${item}`)
.fontSize(16)
.fontColor('#FFFFFF')
}
.width('100%')
.height(80 + (item % 5) * 30)
.backgroundColor(this.getItemColor(item))
.borderRadius(8)
.justifyContent(FlexAlign.Center)
}
}, (item: number) => item.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding(16)
.scrollBar(BarState.Off)
// 【关键③】nestedScroll:父子滚动联动
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST, // 上滑:列表先滚,到顶后外层接管
scrollBackward: NestedScrollMode.SELF_FIRST // 下滑:外层先滚回,TabBar 落位后列表再滚
})
// 【关键④】edgeEffect:禁用回弹 + 保持边缘感知
.edgeEffect(EdgeEffect.None, { alwaysEnabled: true })
}
build() {
NavDestination() {
Stack({ alignContent: Alignment.Top }) {
// 顶部标题栏(固定层级)
Row() {
Text('发现')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width('100%')
.height(this.getAvoidanceHeight())
.padding({ left: 16 })
.backgroundColor('#2C3E50')
.zIndex(2)
// 【关键①】外层 Scroll:height='100%',由内容回推高度
Scroll(this.outerScroller) {
Column() {
// Banner 头图区(上滑时滚出视口)
Column() {
Text('Banner 轮播图')
.fontSize(20)
.fontColor('#FFFFFF')
}
.width('100%')
.height(200)
.backgroundColor('#3498DB')
.justifyContent(FlexAlign.Center)
// Tabs 吸顶区域
Tabs({ index: $$this.selectedTabIndex }) {
TabContent() { this.waterFlowContent() }
.tabBar(this.tabBarBuilder())
}
// 【关键②-a】Tabs 高度 = 剩余空间
.height(`calc(100% - ${this.getAvoidanceHeight()}vp)`)
// 【关键②-b】tabBar 高度由 Builder 内容撑开
.barHeight('auto')
.scrollable(false) // 禁用 Tabs 内置滑动,由子列表承载
.barPosition(BarPosition.Start) // TabBar 在内容上方
.clip(true)
}
.width('100%')
}
.width('100%')
.height('100%') // ← 关键①
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
.hideTitleBar(true)
}
// 模拟数据源
private mockDataSource(): IDataSource {
class MockData implements IDataSource {
private data: number[] = Array.from({ length: 50 }, (_, i) => i + 1);
totalCount(): number { return this.data.length; }
getData(index: number): number { return this.data[index]; }
registerDataChangeListener() {}
unregisterDataChangeListener() {}
}
return new MockData();
}
private getItemColor(index: number): string {
const colors = ['#E74C3C', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6'];
return colors[index % colors.length];
}
}
4.3 常见踩坑指南
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| TabBar 吸顶后抖动 | edgeEffect 未设置 alwaysEnabled: true |
必须显式配置 { alwaysEnabled: true } |
| 列表到顶后外层不响应 | nestedScroll 模式配置错误 |
scrollForward 应为 PARENT_FIRST |
| TabBar 被内容遮挡 | zIndex 未设置或层级错误 |
标题栏 zIndex(2),确保在最上层 |
| 内容溢出屏幕底部 | Tabs 高度未固定 | 使用 calc(100% - avoidanceHeight) |
五、进阶:自定义手势识别器与吸顶效果联动
在前序文章中,我们深入探讨了 HarmonyOS 自定义手势识别器的实现(包括长按、滑动、捏合等手势的识别与分发)。将手势能力与吸顶效果结合,可以创造出更具沉浸感的交互体验。

5.1 场景设计:长按吸顶头部展开快捷菜单
// 手势识别器与吸顶控制器联动
@Entry
@Component
struct GestureStickyPage {
@State isSticky: boolean = false;
@State showQuickMenu: boolean = false;
@State menuOffsetY: number = 0;
private scroller: Scroller = new Scroller();
private readonly stickyThreshold: number = 200;
// 自定义手势识别器:长按检测
private longPressGesture = GestureGroup(GestureMode.Sequence,
LongPressGesture({ duration: 500 })
.onAction(() => {
// 长按吸顶头部时展开快捷菜单
if (this.isSticky) {
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.showQuickMenu = true;
this.menuOffsetY = 48; // 吸顶头部高度
});
}
}),
PanGesture({ direction: PanDirection.Down })
.onActionUpdate((event: GestureEvent) => {
// 下拉手势:收起菜单并触发刷新
if (this.showQuickMenu && event.offsetY > 50) {
this.showQuickMenu = false;
this.triggerRefresh();
}
})
);
build() {
Column() {
// 吸顶头部(支持手势交互)
Row() {
Text('吸顶导航栏')
.fontSize(16)
.fontColor('#FFFFFF')
Blank()
Text('长按展开菜单')
.fontSize(12)
.fontColor('#FFFFFF')
.opacity(0.7)
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor('#E74C3C')
.gesture(this.longPressGesture) // 绑定自定义手势识别器
.shadow({ radius: this.isSticky ? 4 : 0, color: '#000000', offsetY: 2, opacity: 0.15 })
// 快捷菜单(手势触发展开)
if (this.showQuickMenu) {
Column() {
Row() {
ForEach(['刷新', '分享', '设置'], (action: string) => {
Button(action)
.fontSize(12)
.height(32)
.backgroundColor('#FFFFFF')
.fontColor('#333333')
.margin({ right: 8 })
})
}
.width('100%')
.height(48)
.padding({ left: 16 })
.backgroundColor('#F8F9F9')
}
.width('100%')
.transition(TransitionEffect.asymmetric(
TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -20 })),
TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -20 }))
))
}
// 滚动内容
Scroll(this.scroller) {
Column() {
ForEach(Array.from({ length: 50 }, (_, i) => i), (index: number) => {
Row() {
Text(`内容项 ${index + 1}`)
.fontSize(14)
Blank()
Text('>')
.fontColor('#CCCCCC')
}
.width('100%')
.height(50)
.padding({ left: 16, right: 16 })
.backgroundColor(index % 2 === 0 ? '#FFFFFF' : '#FAFAFA')
})
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
.onWillScroll((_, yOffset) => {
// 更新吸顶状态
const newOffset = this.scroller.currentOffset().yOffset + yOffset;
this.isSticky = newOffset >= this.stickyThreshold;
})
}
.width('100%')
.height('100%')
}
private triggerRefresh(): void {
// 触发下拉刷新逻辑
console.info('触发下拉刷新...');
}
}
5.2 手势与吸顶联动的设计原则
- 状态同步:手势识别器通过
onAction回调修改状态变量,吸顶组件通过@State响应式绑定自动刷新,避免手动操作 DOM。 - 动画衔接:使用
animateTo包裹状态变更,确保手势触发后的 UI 反馈(如菜单展开、吸顶阴影变化)具有流畅的过渡效果。 - 冲突消解:当吸顶头部同时绑定点击(Tab 切换)和长按(快捷菜单)时,需通过
GestureMode.Sequence或GesturePriority明确手势优先级,防止事件竞争。
六、性能优化与最佳实践
6.1 滚动性能优化
| 优化手段 | 实现方式 | 收益 |
|---|---|---|
| 懒加载 | LazyForEach + IDataSource |
仅渲染可视区域,降低内存占用 |
| 组件复用 | ListItem 设置 reuseId |
减少组件创建销毁开销 |
| 离屏渲染 | clip(true) 限制绘制区域 |
避免不可见区域的无效渲染 |
| 阴影优化 | 吸顶时动态开启阴影 | 非吸顶状态移除阴影计算 |
6.2 多设备适配
HarmonyOS 应用需同时适配手机、平板、折叠屏等多种形态。吸顶效果在不同设备上的行为应保持一致:
// 响应式吸顶高度计算
import { display } from '@kit.ArkUI';
private getAdaptiveStickyHeight(): number {
const screenWidth = display.getDefaultDisplaySync().width;
// 平板/折叠屏展开态:增加吸顶头部高度
if (screenWidth > 840) {
return 64; // vp
}
return 48; // vp
}
6.3 调试技巧
// 在 DevEco Studio 中开启布局边界调试
.onWillScroll((xOffset, yOffset) => {
// 打印滚动偏移量,辅助调试吸顶阈值
console.debug(`[StickyDebug] offset: ${this.currYOffset}, yDelta: ${yOffset}`);
})
七、总结
本文围绕 HarmonyOS 6(API 23)的吸顶效果,从原生声明式到自定义布局再到手势联动,构建了完整的知识体系:
- 方案一(ListItemGroup):适用于标准分组列表,代码极简,性能最优,是首选方案。
- 方案二(Scroll + Stack):适用于任意组件吸顶,灵活可控,但需手动管理偏移量。
- 方案三(Tabs + nestedScroll):适用于复杂电商/内容场景,四个关键属性缺一不可,是实现专业级吸顶的必备技能。
- 手势联动:将自定义手势识别器与吸顶状态绑定,可拓展出长按菜单、下拉刷新、捏合缩放等高级交互。
吸顶效果看似简单,实则是滚动容器嵌套、偏移量计算、动画过渡、手势分发等多维度能力的综合考验。掌握本文所述的三种方案及其适用边界,开发者即可在 HarmonyOS 应用中游刃有余地实现各类吸顶需求。
转载自:https://blog.csdn.net/u014727709/article/details/163418865
欢迎 👍点赞✍评论⭐收藏,欢迎指正
更多推荐



所有评论(0)