ListView吸顶效果实现原理与实战:分组列表滚动标题固定
发布时间:2026/9/8 10:27:37 作者:尧图编辑部 阅读量:1,286

简介针对Android列表页交互优化的示例工程面向有一定基础、希望实现“滑动ListView标题置顶”与“吸顶效果”的应用开发者。代码包演示了Sticky Header思路、自定义Adapter与滚动事件监听并在工程中引入状态栏透明化处理便于开发者直接移植到新闻、电商分类等常用列表场景。压缩包共58个文件以Java源码、XML布局与配置为主辅以Gradle构建脚本、PNG/GIF效果预览和README说明整体仅1.7MB结构简洁适合快速查阅与二次开发已有949人学习下载。通过研究工程内的12个Java文件与22个XML资源可以掌握吸顶视图的布局组织、列表滚动监听及版本适配方法同时理解透明状态栏与fitsSystemWindows属性的配合方式。对需要精进自定义控件和列表体验优化的开发者来说是一份实用参考。 做Android列表页开发的朋友应该都遇到过这个需求ListView分组展示数据往下滑动时分组标题跟着列表一起滚出屏幕用户滑着滑着就不知道自己到底看到哪个分组了。我之前在做城市选择器和商品分类列表时都被要求加上吸顶效果——让当前分组标题始终固定在屏幕顶部滚动切换分组时标题自动更新。即时通讯的会话列表、外卖App的菜单列表里这个交互也非常常见。但说实话ListView实现吸顶效果的方案资料不少搜索出来的代码大多碎片化严重很多只能做到更新吸顶条文字一旦涉及新标题把旧标题顶开这种丝滑过渡就露馅。这篇文章我把自己的完整实现和踩坑经历整理出来包含可以直接拿去用的布局和代码逻辑适合正在做分组列表吸顶需求、或者想把手头老项目优化一把的Android开发者。1. 吸顶效果到底在解决什么问题从一次需求反推实现思路1.1 一个被很多开发者忽略的体验细节我最早接触吸顶需求是做一个城市选择列表。城市按拼音首字母分组列表很长用户从A区滑到H区如果屏幕顶部没有任何提示滑到一半很容易迷茫我是谁我在哪个字母吸顶效果本质上是把当前分组状态的信息始终固定在用户的视线范围内。它跟你把标题写在顶部导航栏里还不一样它必须跟随列表滚动动态变化你滚到哪个分组它就显示哪个分组的标题。这个信息提示在长列表中的价值比想象中大得多尤其是当列表数据量超过一屏、分组超过五个的时候。很多产品会给这个吸顶条附加额外功能点击吸顶条返回列表顶部、点击吸顶条展开分组索引、吸顶条上显示分组内的统计数字等等。所以做的时候不要把吸顶条想成一个TextView它更可能是一个复杂的交互入口设计上要留好扩展空间。1.2 为什么网上常见的方案总是差一口气搜ListView 吸顶能找到的实现方案我大致归了几类第一类只在OnScrollListener里更新吸顶条文字。这种方式最粗糙它完全没有处理新分组标题把吸顶条顶开的过程。你滚动的时候吸顶条永远固定在顶部新标题item从底部滑上来会被吸顶条硬生生遮住一截然后标题文字突然切换视觉上非常生硬。第二类用ListView的addHeaderView实现吸顶条。这种方式把吸顶条塞进了列表数据里本身思路就不对因为吸顶条一旦作为HeaderView它就会跟着列表滚动你还需要在下一次滚动时把它放回顶部逻辑绕且容易出Bug。第三类直接抛弃ListView改用RecyclerView用ItemDecoration绘制吸顶条。这个方案从技术上也成熟但问题在于很多老项目里的Adapter、点击事件、EmptyView逻辑都是围绕ListView写的迁移成本不小。如果只是为了一个吸顶效果把整个列表框架换掉在排期紧的时候完全不现实。所以我当时的结论是在ListView的容器上叠加一个悬浮吸顶条通过精确的平移和内容切换来模拟顶开效果收益最高。这需要先把这个交互的底层逻辑彻底想清楚。2. 吸顶条让位的核心原理屏幕上的新旧标题博弈2.1 先把三种滚动状态在纸上画清楚吸顶效果的难点从来不是显示标题而是什么时候切换标题内容什么时候给新标题让位。我在代码之前先画了状态图把滚动过程拆成三种状态代码只是这三种状态的翻译。假设吸顶条高度为hListView的第一个可见item可能有两种身份分组标题item、普通数据item。于是有了下面三种场景状态一第一个可见item是普通数据item。说明前一个分组标题已经滚出屏幕顶部当前数据属于哪个分组是明确的吸顶条显示这个分组标题并且固定在顶部不做任何平移。状态二第一个可见item是分组标题item但这个标题item还没有到达吸顶条区域它的顶部坐标top大于吸顶条高度h。此时吸顶条应该继续显示旧标题位置不动。状态三第一个可见item是分组标题item且标题item的top已经落到0到h之间。这时标题item正向上挤入吸顶条的区域吸顶条必须向上平移让路而且要让自己的底部始终紧贴标题item的顶部把它顶上去。很多人漏掉的就是状态三。他们只判断了当前第一个item是不是标题如果是就直接把吸顶条的文字切到新标题并保持固定完全没想过吸顶条此时应该退场。2.2 关键的平移公式setTranslationY的产生过程状态三里吸顶条的平移量怎么算很简单抓住一个核心吸顶条底部要贴合新标题item的顶部。假设吸顶条高度h标题item的顶部相对于ListView的坐标为top这个值直接通过child.getTop()获取。吸顶条默认在顶部底部坐标是h。标题item的顶部如果还没进入吸顶条区域top大于等于h如果正在顶开吸顶条top就在0到h之间。要让吸顶条底部贴合标题item顶部当时吸顶条底部坐标也得是top那么吸顶条整体需要往上平移h - top。用View的translationY表示就是stickyView.setTranslationY(top - h);当top等于h时平移量为0吸顶条纹丝不动当top等于0时平移量为负的h吸顶条整体移出屏幕顶部。这个公式单调、无突变完全符合视觉直觉。很多人问我为什么不直接用scrollY或者getTop去算——因为吸顶条本身不在ListView里面它跟ListView是两个独立的view你直接用ListView的滚动值去推算还得换算吸顶条的高度和item高度容易算错。直接从ListView的第一个可见child上拿top是最稳的。还有一点容易漏当状态三进行到top小于0也就是新标题item的顶部已经完全越过ListView顶部时说明这个新标题已经把旧标题顶死了。此时吸顶条的内容必须切换为新标题并且把translationY恢复为0重新固定在顶部。这个切换要果断不需要动画因为这时候视觉焦点已经在新标题item上了。3. 可直接落地的完整实现布局、Adapter与滚动监听3.1 用FrameLayout叠加吸顶条的结构设计布局我推荐用FrameLayout把ListView和吸顶条叠在一起吸顶条在层级上位于ListView之上。ListView还是正常占据全屏吸顶条放在顶部、宽度填满、高度自适应。FrameLayout android:layout_widthmatch_parent android:layout_heightmatch_parent ListView android:idid/listView android:layout_widthmatch_parent android:layout_heightmatch_parent android:divider#E0E0E0 android:dividerHeight1px / TextView android:idid/stickyHeader android:layout_widthmatch_parent android:layout_heightwrap_content android:background#FF4081 android:paddingTop12dp android:paddingBottom12dp android:paddingLeft16dp android:textColor#FFFFFF android:textSize16sp android:text tools:text当前分组 / /FrameLayout需要注意吸顶条的背景必须不透明否则下面的列表内容会透出来。背景颜色要比列表item的背景更深或更醒目这样吸顶条即使覆盖在item上面用户也能清晰分辨。还有一种做法是给ListView设置paddingTop等于吸顶条高度然后clipToPadding设为false让item从吸顶条下面开始显示。但我实际操作后觉得没必要反而多个padding会让状态判断多一层换算。叠加上去更直接。3.2 多类型Adapter的必备写法数据模型我用了一个简单的对象结构每个条目要么是分组标题要么是普通数据。以一个城市列表为例public class ListData { public String section; // 分组标题如A public String content; // 具体内容如阿拉尔 public boolean isTitle; // 是否是分组标题item }Adapter必须实现多类型item这样在滚动监听里才能通过getItemViewType判断第一个可见item的身份public class SectionAdapter extends BaseAdapter { private static final int TYPE_TITLE 0; private static final int TYPE_CONTENT 1; private final ListListData dataList; public SectionAdapter(ListListData dataList) { this.dataList dataList; } Override public int getViewTypeCount() { return 2; } Override public int getItemViewType(int position) { return dataList.get(position).isTitle ? TYPE_TITLE : TYPE_CONTENT; } Override public int getCount() { return dataList.size(); } Override public Object getItem(int position) { return dataList.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ListData item dataList.get(position); ViewHolder holder; if (convertView null) { holder new ViewHolder(); if (item.isTitle) { convertView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_section_title, parent, false); holder.textView convertView.findViewById(R.id.sectionTitle); } else { convertView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_section_content, parent, false); holder.textView convertView.findViewById(R.id.contentText); } convertView.setTag(holder); } else { holder (ViewHolder) convertView; } holder.textView.setText(item.isTitle ? item.section : item.content); return convertView; } static class ViewHolder { TextView textView; } }这段代码看着简单但有几个坑。getViewTypeCount必须返回2否则多类型不生效这是BaseAdapter的老规矩。getView里的if/else分支必须严格按照item类型来inflate布局不能图省事全部用同一个布局否则后面的复用会乱。另外holder在convertView非空时直接复用完全没有区分类型。这在多类型Adapter里确实有隐患如果同一个convertView被不同类型复用会出现holder.textView引用错乱。保险的写法是把type也存进tag里或者根据item.isTitle再走一次分支。我实测中ListView的convertView复用是按类型分别缓存的不同类型不会互相复用所以理论上没问题但为了稳妥建议在getView开头按类型重新确认布局。3.3 OnScrollListener里的核心逻辑逐行拆解布局和Adapter就绪后核心在OnScrollListener里。listView.setOnScrollListener(new AbsListView.OnScrollListener() { Override public void onScrollStateChanged(AbsListView view, int scrollState) { // 不需要处理 } Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { View firstChild listView.getChildAt(0); if (firstChild null) { return; } // 如果用了addHeaderView这里需要减去header的数量 int realPosition firstVisibleItem - headerCount; if (realPosition 0 || realPosition adapter.getCount()) { return; } int stickyHeight stickyHeader.getHeight(); int itemType adapter.getItemViewType(realPosition); int top firstChild.getTop(); if (itemType TYPE_CONTENT) { // 第一个可见item是普通数据吸顶条固定显示它所属的分组 stickyHeader.setText(getSectionForPosition(realPosition)); stickyHeader.setTranslationY(0); return; } // 第一个可见item是标题 if (top stickyHeight) { // 标题还没顶到吸顶条吸顶条显示旧分组固定不动 stickyHeader.setText(getSectionForPosition(realPosition - 1)); stickyHeader.setTranslationY(0); } else if (top 0) { // 标题正在顶开吸顶条吸顶条向上让位 stickyHeader.setText(getSectionForPosition(realPosition - 1)); stickyHeader.setTranslationY(top - stickyHeight); } else { // 标题已经越过顶部吸顶条切换为新标题并复位 stickyHeader.setText(getSectionForPosition(realPosition)); stickyHeader.setTranslationY(0); } } });getSectionForPosition方法负责从当前位置向前查找最近的分组标题private String getSectionForPosition(int position) { for (int i position; i 0; i--) { ListData data adapter.getData().get(i); if (data.isTitle) { return data.section; } } return ; }这段代码的核心思路就是我在第2节梳理的状态机。我实际跑下来最流畅的效果是textView内容切换和translateY复位在同一个frame内完成不要在文本切换前后加postDelayed否则快速滚动时能明显看到闪烁。4. 实测中的坑闪烁、错位与性能4.1 文本切换时机不对导致的闪烁我第一次实现时把文本切换判断放在了isTitle判定后面只要第一个可见item是标题就立刻把吸顶条文字切到新标题。结果滚动时出现了明显的吸顶条文字先变然后吸顶条才开始让位的闪烁。原因很直接当新标题item还在state二top大于h时它距离吸顶条还有段距离但文字已经切换用户会先看到新标题出现在吸顶条上再看到list里的新标题慢慢往上顶两个标题同时存在视觉信息就错乱了。正确的顺序永远是先判断位置关系再决定内容。内容属于旧标题还是新标题完全由top决定而不是由当前第一个item是不是标题决定。我调试这类问题时有个习惯在onScroll里用Log打印realPosition、top、stickyHeight这三个值然后手动慢速滑动观察top跨过0和h这两个临界点时吸顶条的行为。这个方法比纯看代码推理高效得多。4.2 HeaderView和EmptyView带来的position偏移如果ListView用了addHeaderViewgetFirstVisibleItem默认会把它也算进去。也就是说header占据position 0列表真实数据从position 1开始。这时候realPosition firstVisibleItem - headerCount就必须要做。EmptyView也有坑。列表为空时ListView可能显示EmptyView此时getChildAt(0)返回的可能是EmptyView的childtype判断必然出错。我在真实项目里加过一次判空发现EmptyView出现时firstVisibleItem也可能等于0但adapter.getCount()为0所以realPosition adapter.getCount()的判断能兜住。但还有个更隐蔽的情况如果数据从无到有动态变化ListView的重绘时机和onScroll回调时机不完全同步可能出现getChildAt(0)拿到的还是上一个状态的view。我给这种场景的临时方案是在setAdapter或notifyDataSetChanged之后手动调用一次onScroll逻辑强制刷新吸顶条。4.3 标题查找算法在长列表下的性能隐患getSectionForPosition在线性遍历分组标题。如果列表有几千个item而分组标题很少每次onScroll回调都要向前遍历几十上百个节点这个开销在低端机上还是有压力的。优化思路是先维护一个sectionIndex数组在setData后预先算好每个position所属的分组标题String[] sectionCache new String[dataList.size()]; String currentSection ; for (int i 0; i dataList.size(); i) { if (dataList.get(i).isTitle) { currentSection dataList.get(i).section; } sectionCache[i] currentSection; }这样在OnScrollListener里直接通过realPosition查数组时间复杂度降到O(1)。我实测在3000条数据、60个分组的手机上这个缓存优化之后滚动帧率提升明显起码没有再出现掉帧时列表一卡一卡的现象。另外onScroll里尽量避免new对象比如不要每次都new StringBuilder来拼字符串。文本固定的话直接setText(R.string.xxx)也行。setText本身在极高频调用下性能不差但如果能和上一次的字符串比较一下相同就跳过还能省掉内部的invalidate流程。5. 后续演进从ListView到RecyclerView的吸顶方案对比5.1 RecyclerView吸顶的ItemDecoration实现思路如果你是新项目我还是推荐直接用RecyclerView。吸顶效果在RecyclerView里有更正统的实现方式ItemDecoration。它的思路是在onDrawOver方法中先找到当前第一个可见item的位置通过adapter.getItemViewType判断它是不是标题item然后如果是数据item直接绘制当前分组标题在顶部。如果是标题item拿到它相对于RecyclerView的top坐标如果top小于吸顶条高度需要在吸顶条的top位置上绘制该标题并往上偏移偏移量是吸顶条高度减去top的绝对值。核心逻辑跟ListView版本几乎一样区别在于ItemDecoration不需要额外的吸顶条View它直接画在canvas上。但代价是事件处理麻烦ItemDecoration绘制出来的内容不占view层级想要给吸顶条加点击事件就得自己在onTouchEvent里做坐标区域的命中判断。RecyclerView还有另一个方案在布局里也叠一个吸顶条view然后监听RecyclerView的scroll事件逻辑跟ListView这套几乎完全通用。如果你在ListView上已经弄明白了我前面那套状态机切到RecyclerView只是换个容器而已。5.2 两种方案的取舍与我的建议老项目ListView迁移RecyclerView的成本不只是替换Adapter还包括EmptyView、HeaderView、item点击、滚动监听里的一堆业务逻辑。如果只是想要吸顶效果我建议直接采用本文第3节的方案半天时间就能接好。如果是从零开始的新项目肯定优先RecyclerView这不仅是为了吸顶效果更是为了性能和长期维护。RecyclerView的ViewHolder机制、DiffUtil、LayoutManager扩展性都远胜ListView这些优势在列表复杂化之后会越来越明显。我个人的经验是不要为了一个吸顶效果去决定整个列表选型。先评估项目里的列表复杂度和迁移风险在现有架构上做最小改动才是最稳的选择。如果需求里还要求点击吸顶条能快速返回当前分组列表的顶部位置比如城市选择器里点击吸顶条展开字母索引面板有一个小技巧在吸顶条上挂一个普通的Click事件在事件里拿到当前吸顶条显示的分组标题然后遍历sectionIndex列表找到第一个匹配标题item的位置再调用listView.setSelection(index)跳过去。setSelection不会触发OnScrollListener的实时滚动回调跳转完成后手动刷一次吸顶条内容就行。这个交互后来在评审时被产品经理专门夸过吸顶条不只是一个现状展示还是一个快捷操作入口体验维度完全不一样。本文还有配套的精品资源点击获取