简介:针对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 先把三种滚动状态在纸上画清楚
吸顶效果的难点从来不是"显示标题",而是"什么时候切换标题内容""什么时候给新标题让位"。我在代码之前先画了状态图,把滚动过程拆成三种状态,代码只是这三种状态的翻译。
假设吸顶条高度为h,ListView的第一个可见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_width="match_parent" android:layout_height="match_parent"> <ListView android:id="@+id/listView" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="#E0E0E0" android:dividerHeight="1px" /> <TextView android:id="@+id/stickyHeader" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#FF4081" android:paddingTop="12dp" android:paddingBottom="12dp" android:paddingLeft="16dp" android:textColor="#FFFFFF" android:textSize="16sp" 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 List<ListData> dataList; public SectionAdapter(List<ListData> 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用了addHeaderView,getFirstVisibleItem默认会把它也算进去。也就是说,header占据position 0,列表真实数据从position 1开始。这时候realPosition = firstVisibleItem - headerCount就必须要做。
EmptyView也有坑。列表为空时,ListView可能显示EmptyView,此时getChildAt(0)返回的可能是EmptyView的child,type判断必然出错。我在真实项目里加过一次判空,发现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的实时滚动回调,跳转完成后手动刷一次吸顶条内容就行。这个交互后来在评审时被产品经理专门夸过,吸顶条不只是一个"现状展示",还是一个"快捷操作入口",体验维度完全不一样。
本文还有配套的精品资源,点击获取