【共创季稿事节】鸿蒙ArkTS粘性标题布局深度解析
鸿蒙原生 ArkTS 布局深度解析:List 粘性标题(Sticky Header)从入门到精通
一、引言
在移动应用的日常使用中,有一种交互模式几乎无处不在——当你翻开通讯录、浏览商品分类、查看设置菜单时,列表的分组标题总会优雅地「粘」在屏幕顶部,直到下一组的标题将它推走。这个看似简单的效果,在产品体验上解决了两个核心问题:一是提供位置上下文,让用户始终知道自己当前浏览的是哪个分组;二是减少操作层级,无需反复回顶部查看分类即可快速定位信息。
在 HarmonyOS NEXT(API Version 24)中,ArkTS 声明式框架为开发者提供了开箱即用的粘性标题能力,通过List.sticky()属性和ListItemGroup组件的组合,只需少量代码就能实现原生体验的 Sticky Header 效果。本文将从零开始,通过一个完整的「通讯录」示例,深入剖析其实现原理、API 细节、常见陷阱和最佳实践。
本文配套的完整示例代码已通过hvigorw assembleHap构建验证,BUILD SUCCESSFUL,可直接在 HarmonyOS NEXT 真机或模拟器上运行。
二、为什么需要 Sticky Header?
2.1 用户场景还原
假设你正在浏览一个包含数百条联系人的通讯录应用,列表按姓氏首字母分为 A~Z 共 26 个分组。若没有粘性标题:你需要频繁地上下滚动来回忆当前在哪个字母段;每一次分组切换都是一次认知负担,影响了浏览的流畅性。
加入 Sticky Header 后,这一切变得浑然天成:当前分组的标题始终固定在列表顶部,你不需要思考「我在哪」,界面本身就给出了答案。
2.2 适用场景归纳
| 场景 | 典型应用 | 分组依据 |
|---|---|---|
| 通讯录 / 联系人 | 电话 App、企业通讯录 | 姓氏首字母 |
| 商品分类 | 电商 App 分类页 | 品类(食品、数码、服饰…) |
| 设置页面 | 系统设置、App 内部设置 | 功能模块(网络、显示、声音…) |
| 时间线 / 日志 | 聊天记录、事件流水 | 日期(今天、昨天、更早) |
| 地区选择 | 地址选择器 | 省份 / 城市首字母 |
几乎任何「分组 + 长列表」的组合都可以从 Sticky Header 中获益。
三、HarmonyOS NEXT 中的 List 体系概述
3.1 从 List 到 ListItemGroup
在 ArkTS 中,List是最核心的长列表容器组件。与传统的Scroll+Column手动实现列表不同,List内建了:高效的回收复用机制(只渲染可见区域的 ListItem)、丰富的滚动事件与控制 API(scrollTo、scrollEdge、onScrollIndex)、粘性标题支持(通过sticky属性声明式启用)以及多列网格布局(通过lanes属性支持)。
而ListItemGroup是List的子容器,它将零散的ListItem聚合成一个逻辑分组。ListItemGroup的header构造函数参数接受一个自定义组件,这个组件就是 Sticky Header 的视觉载体。
3.2 布局层级关系
List (复用长列表容器) ├── ListItemGroup (分组 A) │ ├── header → GroupHeader (粘性标题组件) ★ │ ├── ListItem → ContactItem (联系人 A1) │ ├── ListItem → ContactItem (联系人 A2) │ └── ListItem → ContactItem (联系人 A3) ├── ListItemGroup (分组 B) │ ├── header → GroupHeader (粘性标题组件) ★ │ ├── ListItem → ContactItem (联系人 B1) │ └── ListItem → ContactItem (联系人 B2) └── ...其中最关键的两点:header是 ListItemGroup 的内置插槽,不是自行插入的普通行,框架能识别它并赋予粘性行为;粘性行为的开关在 List 层面,由.sticky(StickyStyle.Header)统一启用。
四、核心 API 详解
4.1List.sticky()方法
List(){...}.sticky(style:StickyStyle)| 枚举值 | 行为 | 何时使用 |
|---|---|---|
StickyStyle.Header | 分组标题吸附在列表顶部,不跟随滚动 | 绝大多数场景,推荐 |
StickyStyle.Normal | 分组标题跟随列表滚动,不吸附 | 标题随列表整体滑出的特殊场景 |
特别注意:sticky属性仅在ListItemGroup提供了header时才生效。如果 ListItemGroup 没有 header,即使设置了.sticky(StickyStyle.Header),也不会有任何粘性效果。
4.2ListItemGroup构造函数
ListItemGroup(options:{header?:CustomBuilder;footer?:CustomBuilder})- header:组件形式的标题。传入一个自定义组件(用
@Component装饰的结构体),因为header的类型是CustomBuilder。 - footer:可选的组尾,用法与 header 对称。footer 始终位于分组末尾,但不具备粘性行为。
4.3 数据驱动与 ForEach
示例中使用ForEach遍历分组和联系人:
ForEach(this.groups,(group:ContactGroup)=>{ListItemGroup({header:GroupHeader({title:`★${group.groupName}`})}){ForEach(group.contacts,(contact:ContactInfo)=>{ListItem(){ContactItem({contact:contact})}},(contact:ContactInfo)=>contact.phone)}},(group:ContactGroup)=>group.groupName)注意两点:
keyGenerator参数:ForEach的第三个参数是键值生成函数。对于分组用group.groupName作为唯一键,对于联系人用contact.phone作为唯一键,能显著提升列表 diff 更新性能。- 嵌套约束:
List的直接子元素必须是ListItem或ListItemGroup(它们内部再嵌套ListItem),不能把普通Row/Column直接放在List中。
五、代码逐段精读:从数据模型到渲染
5.1 数据层:ContactInfo 与 ContactGroup
// ContactInfo.etsexportinterfaceContactInfo{name:string;phone:string;}// StickyHeaderDemo.etsinterfaceContactGroup{groupName:string;contacts:ContactInfo[];}数据模型遵循扁平化 + 分组嵌套原则。ContactGroup外层按字母分组,内层是联系人列表,天然对ListItemGroup+ForEach的嵌套渲染模式友好。
5.2 组件层:ContactItem
@Componentstruct ContactItem{privatecontact:ContactInfo={name:'',phone:''};build(){Row(){Circle().width(44).height(44).fill($r('sys.color.ohos_id_color_component_normal')).margin({right:12})Column(){Text(this.contact.name).fontSize(16).fontWeight(FontWeight.Medium)Text(this.contact.phone).fontSize(13).fontColor($r('sys.color.ohos_id_color_text_secondary'))}.alignItems(HorizontalAlign.Start)Blank()Image($r('sys.media.ohos_ic_public_arrow_right')).width(16).height(16).fillColor($r('sys.color.ohos_id_color_text_secondary'))}.width('100%').height(64).padding({left:16,right:16}).backgroundColor(Color.White)}}设计要点:头像用Circle组件占位,减少资源依赖;Blank()撑满剩余空间实现左右对齐,比计算百分比更简洁;$r()引用系统级资源,自动适配深色/浅色模式。
5.3 组件层:GroupHeader
@Componentstruct GroupHeader{privatetitle:string='';build(){Row(){Text(this.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor($r('sys.color.ohos_id_color_text_primary_10')).padding({left:16})}.width('100%').height(36).backgroundColor($r('sys.color.ohos_id_color_sub_background')).alignItems(VerticalAlign.Center)}}设计要点:高度 36vp 较为紧凑,避免过多占用列表空间;背景色使用系统ohos_id_color_sub_background,自动适配双色模式;15fp 加粗字体在列表上下文中足够醒目。
5.4 页面层:StickyHeaderDemo
@Entry@Componentstruct StickyHeaderDemo{@Stateprivategroups:ContactGroup[]=[];aboutToAppear():void{this.groups=this.buildMockData();}build(){Column(){// 顶部标题栏Row(){Text('通讯录').fontSize(20).fontWeight(FontWeight.Bold)}.width('100%').height(52).padding({left:16,right:16}).backgroundColor(Color.White).alignItems(VerticalAlign.Center)// 核心:List + Sticky HeaderList(){ForEach(this.groups,(group:ContactGroup)=>{ListItemGroup({header:GroupHeader({title:`★${group.groupName}`})}){ForEach(group.contacts,(contact:ContactInfo)=>{ListItem(){ContactItem({contact:contact})}},(contact:ContactInfo)=>contact.phone)}.divider({strokeWidth:0.5,color:'#e0e0e0',startMargin:72})},(group:ContactGroup)=>group.groupName)}.width('100%').height('100%').sticky(StickyStyle.Header)// ★ 核心开关}.width('100%').height('100%').backgroundColor($r('sys.color.ohos_id_color_background'))}}最外层Column中从上到下依次是「顶部标题栏」和「List」,这是最常见的页面布局模式。aboutToAppear生命周期钩子中调用buildMockData()初始化数据,这是 ArkTS 推荐的做法。
5.5 数据生成函数
privatebuildMockData():ContactGroup[]{constraw=[{group:'A',names:['Alice 爱丽丝','Aaron 亚伦','Amy 艾米','Andy 安迪']},{group:'B',names:['Bob 鲍勃','Bella 贝拉','Ben 本','Betty 贝蒂','Brian 布莱恩']},// ... 共 13 个分组,A~P,50+ 条联系人];constgroups:ContactGroup[]=[];letphoneBase=13000000000;for(constitemofraw){groups.push({groupName:item.group,contacts:item.names.map(name=>({name,phone:String(phoneBase++)}))});}returngroups;}使用phoneBase递增生成唯一电话号码,保证keyGenerator键值唯一。中英文混合名字更有真实感。13 个分组、50+ 条联系人的数据量恰到好处,足够展示粘性效果的完整切换动效。
六、Sticky 效果的完整交互流程
6.1 吸附与推出
当用户向下滚动列表时:
- 分组的 header 以普通列表项的身份进入可视区;
- header 到达 List 容器顶部边缘,框架检测到 sticky 已开启,将其切换到「吸附模式」,固定在容器顶部;
- 该分组的 ListItem 继续在 header 下方滚动穿过;
- 下一分组的 header 从底部进入,两标题上边缘触碰时,当前吸附的 header 被平滑推出。
6.2 反向滚动
用户向上滚动时:
- 正在吸附的 header 保持固定,其所属 ListItem 向下滚动回到视口;
- 当本组最后一项离开底部时,header 释放吸附,跟随列表上移;
- 上一分组的 header 重新进入视口并开始吸附。
6.3 行为总结
| 滚动方向 | header 行为 | 用户感知 |
|---|---|---|
| 向下 | 新 header 吸附,旧 header 推走 | 标题切换流畅自然 |
| 向上(本组内) | header 保持吸附 | 始终知道当前分组 |
| 向上(跨组) | 旧 header 推回,新 header 吸附 | 标题倒序切换 |
七、实用技巧与最佳实践
7.1 在粘性标题上增加交互
粘性标题不仅是展示文字,完全可以响应交互。例如添加点击事件跳转到分组顶部:
@Componentstruct GroupHeader{privatetitle:string='';privateonTitleTap?:()=>void;build(){Row(){Text(this.title).fontSize(15).fontWeight(FontWeight.Bold)}.width('100%').height(36).backgroundColor($r('sys.color.ohos_id_color_sub_background')).onClick(()=>{this.onTitleTap?.();})}}7.2 混合使用不同风格的 ListItemGroup
一个 List 中可以混合有 header 和无 header 的 ListItemGroup,无 header 的分组不参与粘性行为:
List(){ListItemGroup(){/* 常用联系人:无 header → 无粘性 */}ForEach(this.contacts,(group)=>{ListItemGroup({header:GroupHeader(...)}){/* 其他分组 */}})}.sticky(StickyStyle.Header)7.3 性能优化建议
- 精简 header 组件复杂度:header 在吸附期间持续参与布局计算,避免嵌套太深的组件树。
- 合理使用 keyGenerator:提供唯一且稳定的键值,避免使用索引(index)作为键值。
- 超长列表使用
LazyForEach:数据量超过 1000 项时,LazyForEach按需创建销毁组件,内存更友好。
7.4 常见陷阱与解决方案
| 陷阱 | 原因 | 解决方案 |
|---|---|---|
| header 高度不固定 | 不同分组 header 高度不同,推出衔接不平滑 | 所有分组 header 保持统一高度 |
header 内@State数据不刷新 | 数据变化后 header 未按预期刷新 | 动态数据提升到父组件,通过@Link传递 |
| List 中混入非 ListItem 子元素 | List()内直接放Text/Row | 放在 List 外部,或包裹在 ListItem 中 |
| 粘性效果未生效 | 未设置.sticky()或 ListItemGroup 无 header | 检查.sticky(StickyStyle.Header)和 header 赋值 |
八、与其他平台的对比
| 平台 | 实现方式 | 代码量 |
|---|---|---|
| HarmonyOS NEXT | List.sticky()+ListItemGroup | ~10 行 |
| iOS UIKit | UITableView.sectionHeadersPinToVisibleBounds | 1 行 |
| iOS SwiftUI | List+Section默认自带 | 0 行 |
| Android RecyclerView | ItemDecoration+onDrawOver自定义绘制 | ~50 行 |
| Android Compose | LazyColumn+stickyHeaderlambda | ~3 行 |
| Flutter | SliverPersistentHeader+CustomScrollView | ~40 行 |
| React Native | SectionList.stickySectionHeadersEnabled | 1 行 |
HarmonyOS NEXT 的实现属于第一梯队(声明式 + 少代码量),与 SwiftUI 和 Compose 站在同一水平线上,充分借鉴了现代声明式 UI 框架的最佳实践。
九、扩展应用:粘性 Tab
Sticky Header 的概念可以泛化为「粘性 Tab」——在电商详情页中,评价、详情、推荐等 Tab 栏在滚动时吸附在顶部。
实现思路:将 Tab 栏作为ListItemGroup的header,每个 Tab 对应的内容作为该分组的ListItem,用户点击 Tab 时通过scrollToIndex跳转:
ListItemGroup({header:ProductTabBar({tabs:['评价','详情','推荐'],onTabChange:(i)=>scroller.scrollToIndex(i*50)})}){ListItem(){ReviewSection()}ListItem(){DetailSection()}ListItem(){RecommendSection()}}这种实现方式比手动监听滚动位置更简洁,且利用框架原生粘性机制,稳定性和性能都更好。
十、构建验证
10.1 构建命令
hvigorw assembleHap --no-daemon预期输出:BUILD SUCCESSFUL。
10.2 在模拟器/真机上验证
- 打开 DevEco Studio,在
build-profile.json5中确认compileSdk = 24; - 连接 HarmonyOS NEXT 设备或启动 API 24 模拟器;
- 点击 Run,选择
entry模块; - 应用启动后,首页为导航卡片,点击「进入演示」跳转到通讯录页面;
- 上下快速滑动,观察字母标题是否在顶部吸附和切换。
十一、结语
通过本文的完整示例,我们从产品设计、API 原理、代码实现到性能优化,全面解析了 HarmonyOS NEXT 中 List 粘性标题的布局方式。核心要点可以概括为四句话:
- 一个插槽:
ListItemGroup的header插槽是粘性标题的载体; - 一个开关:
List.sticky(StickyStyle.Header)一行代码开启吸附效果; - 两级复用:
ForEach驱动分组与列表项的渲染,List框架负责回收复用; - 零动画代码:吸附与推出动画由系统自动完成,开发者无需介入。
作为 HarmonyOS NEXT 声明式 UI 的一个重要组成,Sticky Header 的 API 设计体现了「约定优于配置」的理念——框架替你做了 80% 的工作,剩下的 20% 通过组件化和数据驱动交给开发者灵活定制。
一个优秀的 UI 框架,不是让开发者写更少的代码,而是让每一行代码都产生更大的价值。List.sticky+ListItemGroup正是这一理念的绝佳注脚。
本文所有示例代码均基于 HarmonyOS NEXT API 24(API Version 24)、ArkTS 声明式开发范式编写,已通过
assembleHap构建验证。项目源码路径:
entry/src/main/ets/pages/StickyHeaderDemo.ets
配套模型文件:entry/src/main/ets/pages/ContactInfo.ets
导航入口文件:entry/src/main/ets/pages/Index.ets
