做 Android 列表的早晚都会遇到这个需求列表滚动时顶部分组标题要固定不动也就是俗称的 Sticky Header 悬浮效果。通讯录按字母分组、商品列表按分类筛选、小说阅读按卷章跳转这些场景里都能看到它的身影。我最早做这个功能时第一反应是去网上搜“RecyclerView 悬浮效果”出来一堆 ItemDecoration 手绘方案看着很厉害但真放到项目里一旦悬浮区要放图片、按钮、跟随滑动做动画Canvas 手绘那套维护成本就会让人头疼。这篇我把踩过的坑和最终沉淀下来的实现思路完整写出来核心方向是“悬浮 View 滚动监听”同时会把 ItemDecoration 方案、三方库方案、不同 Header 高度、嵌套滚动这些衍生问题一起讲明白适合正在做这类功能、想避开常见坑的 Android 开发者参考。1. 方案选型为什么我不推荐一上来就写 ItemDecoration1.1 三种主流实现思路的横向对比做顶部悬浮效果先别急着写代码把方案选对后面能省很多事。我梳理了三个常见方向对比维度悬浮 View 滚动监听ItemDecoration 绘制三方库 StickyHeaderDecoration实现方式在列表上方放一个真实 View监听滚动更新数据onDrawOver 里用 Canvas 绘制背景和文字基于 ItemDecoration 封装交互能力强悬浮 View 本身可点击、可嵌套复杂布局弱点击需要手动命中检测弱点击同样要靠自己扩展复杂 UI 支持好图片、按钮、输入框都能直接放差用 Paint 画文字和图形非常痛苦一般只适合文字类悬浮头性能滚动时只做一次 View 更新开销很小每帧都走一次 draw绘制逻辑要克制每帧也可能走 draw注意别做耗时操作维护成本低代码逻辑直观高绘制和 Touch 检测都容易出边界问题低但遇到定制需求要读源码改适用场景大多数业务项目推荐首选纯文字、视觉简单、追求极致性能原型验证、悬浮区只有文字的轻量项目从这个对比能看出来并不是说 ItemDecoration 方案不能做悬浮效果而是它把简单问题变复杂了。悬浮头本质上是一个“需要展示数据、需要响应点击”的 UI 区域系统已经给了 View 这么好用的抽象没必要绕回 Canvas 去自己画。我之前在一个刷新频率很高的行情列表里试过纯绘制方案悬浮数字要实时跳动结果每次 draw 都要重新排版文本、计算颜色代码越写越重后来直接换成了悬浮 View代码量反而降了一半。所以我现在的选择逻辑很简单除非悬浮区只是纯色块加一行不会变化的文字否则一律用悬浮 View。1.2 悬浮 View 方案的核心思路悬浮 View 方案的实现思路可以概括成一句话在 RecyclerView 的父容器里叠放一个真实 View利用滚动监听动态修改它的数据和位置。具体到布局结构典型做法是用 FrameLayout 作为容器RecyclerView 铺满整个区域悬浮 View 作为兄弟节点盖在它上方。列表滚动时悬浮 View 固定在父容器顶部不动但我们监听 RecyclerView 的滚动事件算出“当前应该显示哪个分组的 Header”然后把悬浮 View 的内容切换成对应分组的数据。这样做的好处是悬浮 View 可以像普通 View 一样使用布局文件、设置背景、加点击事件、绑定图片不需要手工做任何绘制。它和 RecyclerView 里的 Header Item 是两套独立的 View各管各的列表里的 Header Item 负责在滚动中自然进出屏幕悬浮 View 负责在顶部稳定驻留。很多人一开始绕不过弯来觉得“我是不是该把列表里的第一个 Header 抽出来放到外面”其实不是外界悬浮 View 和列表内 Header 是平行关系只是通过数据联动保持内容一致。1.3 坐标换算其实是很多人的盲区悬浮 View 方案看着简单真正写起来最容易翻车的点是搞不清“悬浮 View 应该显示哪个 Header”。这个问题的本质是 RecyclerView 的滚动位置和数据结构之间的对应关系。RecyclerView 内部是一个滚动的线性列表我们的业务数据也是线性的所以思路可以这样展开任何时候屏幕上第一个可见的 item 都有一个索引假设它叫 firstVisiblePosition。只要从 firstVisiblePosition 开始向前遍历业务数据找到最近的一个 Header 类型 item这个 Header 就是当前应该在顶部悬浮的那个。向前找而不是向后找是因为 firstVisiblePosition 当前处在的位置“上方最近的分组”就是用户此刻正在浏览的分组。举个例子理解数据顺序是 [Header A, 内容1, 内容2, Header B, 内容3, 内容4]。当 firstVisiblePosition 0 时悬浮头显示 Header A滚动到内容1成为第一个可见项也就是 firstVisiblePosition 1向前找最近的 Header还是 Header A所以悬浮头继续显示 Header A一直滚到内容4成为第一个可见项即 firstVisiblePosition 5此时向前找最近 Header 已经是 Header B 了悬浮头就切换为显示 Header B。掌握了这个映射逻辑悬浮数据更新就稳了位置始终固定在父容器顶部不会出现悬浮头和列表里 Header 内容对不上的情况。2. 数据与布局先把地基打牢2.1 数据模型设计悬浮效果的本质是按组展示所以数据层首先要能区分“这个 item 是 Header 还是普通内容”。我用的是一种扁平的线性结构data class ListItem( val type: Int, // TYPE_HEADER 或 TYPE_CONTENT val groupId: String, // 分组标识比如 A、B 或 第1组 val title: String, // 显示文字Header 显示组名Content 显示条目内容 val content: String? null )注意两点。第一尽量用扁平结构而不是嵌套结构因为 RecyclerView 本身接收的就是线性列表把嵌套分组拍平后List 的下标可以直接对应 item 下标写 Adapter 和悬浮查找逻辑都会顺畅很多。第二Header 必须是每个分组的第一条数据也就是说业务上生成数据时要先 add 一条 ListItem(TYPE_HEADER, 第X组, ...)再 add 这一组的内容条目。有的同学会问groupId 字段是不是多余如果列表本身严格有序直接判断 type 也能区分 Header 和 Content但加上 groupId 后后续如果要做“点击悬浮头跳转到分组顶部”“分组内统计数量”等扩展会非常好用建议保留。2.2 布局文件的写法页面最外层的布局用 FrameLayoutRecyclerView 和悬浮 View 是兄弟节点。这里有一个关键顺序问题悬浮 View 要声明在 RecyclerView 后面这样悬浮 View 才能叠加在列表上层。FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.recyclerview.widget.RecyclerView android:idid/recyclerView android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse / LinearLayout android:idid/floatingHeader android:layout_widthmatch_parent android:layout_height48dp android:orientationhorizontal android:background#FFFFFF android:elevation4dp android:gravitycenter_vertical android:paddingStart16dp TextView android:idid/tvFloatingTitle android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:textSize16sp android:textStylebold / /LinearLayout /FrameLayout悬浮 View 高度为什么建议和列表里的 Header Item 保持一致因为这样滚动切换时视觉过渡最自然。列表里的 Header A 滚出屏幕顶部的那一刻悬浮 View 刚好也准备显示 Header B两者高度一致就不会出现内容跳变或者遮挡面积不匹配的问题。我这里 Header Item 高度是 48dp悬浮 View 也写 48dp是最常用的做法。列表里的两种 Item 布局也很简单!-- item_header.xml -- TextView xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/tvHeader android:layout_widthmatch_parent android:layout_height48dp android:background#EFEFEF android:gravitycenter_vertical android:paddingStart16dp android:textSize16sp android:textStylebold /!-- item_content.xml -- TextView xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/tvContent android:layout_widthmatch_parent android:layout_height64dp android:gravitycenter_vertical android:paddingStart16dp android:textSize14sp android:textColor#666666 /2.3 Adapter 多类型 Item 的实现既然有 Header 和 Content 两种 itemAdapter 就要重写 getItemViewType 方法根据 ListItem 的 type 字段返回对应类型。这是 RecyclerView 的基础功但很容易写错我见过不少同学在这里犯低级错误直接导致 ClassCastException。完整写法参考class StickyAdapter( private val list: MutableListListItem ) : RecyclerView.AdapterRecyclerView.ViewHolder() { companion object { const val TYPE_HEADER 0 const val TYPE_CONTENT 1 } class HeaderVH(private val binding: ItemHeaderBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: ListItem) { binding.tvHeader.text item.title } } class ContentVH(private val binding: ItemContentBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: ListItem) { binding.tvContent.text item.title } } override fun getItemCount() list.size override fun getItemViewType(position: Int): Int list[position].type override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): RecyclerView.ViewHolder { return when (viewType) { TYPE_HEADER - { val binding ItemHeaderBinding.inflate( LayoutInflater.from(parent.context), parent, false ) HeaderVH(binding) } else - { val binding ItemContentBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ContentVH(binding) } } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is HeaderVH - holder.bind(list[position]) is ContentVH - holder.bind(list[position]) } } }这个 Adapter 的逻辑是无状态、纯展示的数据更新通过外部改 list 后再调用 notifyDataSetChanged 来触发。注意 getItemViewType 一定要用数据里的 type 字段做判断不要脑补 position % 2 这种规律一旦数据结构调整分分钟踩雷。3. 核心逻辑滚动监听与悬浮数据联动实战3.1 向前查找悬浮 Header 的具体实现布局和 Adapter 都准备好了剩下的核心就是滚动监听。我把悬浮逻辑打包成一个独立的 Helper 类方便在多个页面复用思路是监听 RecyclerView 的滚动事件每次都重新计算“当前应该显示哪个 Header”然后通过回调通知外部刷新悬浮 View。先看最关键的前向查找逻辑fun update() { val lm recyclerView.layoutManager as? LinearLayoutManager ?: return val adapter recyclerView.adapter as? StickyAdapter ?: return val firstVisible lm.findFirstVisibleItemPosition() if (firstVisible RecyclerView.NO_POSITION) return val data adapter.data() var targetPos firstVisible // 从第一个可见 item 向前找最近的 Header while (targetPos 0) { if (data[targetPos].type StickyAdapter.TYPE_HEADER) { break } targetPos-- } if (targetPos 0) return val targetItem data[targetPos] // 只有 Header 位置发生变化时才通知外部刷新悬浮 View if (targetPos ! currentHeaderPos) { currentHeaderPos targetPos onHeaderChanged.invoke(targetItem) } }这里有三个点值得细说。第一为什么从第一个可见 item 向前找因为悬浮头要跟随用户当前浏览的分组。用户此刻看到的列表内容总是从某个分组开始的这个分组就是“第一个可见 item 所在的组”。向前找最近 Header实际上就是在找“这个分组的 Header”。第二为什么 targetPos 是 Header 的位置而不是 Header 的 groupId因为同一时刻可能因为数据渲染问题导致多个 Header 拥有相同的 groupId虽然不常见但为了严谨用位置标记更准确。而且 targetPos 还能直接作为 RecyclerView 滚动定位的入参跳转到指定分组时用得上。第三为什么要判断 targetPos ! currentHeaderPos 才回调因为在滚动过程中可能几十个 Content item 都属于同一个组悬浮头内容完全不需要更新。如果不做拦截每次滚动都会回调一次 setText虽然 TextView.setText 本身不算重但高频无用刷新会让列表在滚动时产生不必要的测量还可能出现肉眼可见的闪烁。加一个位置缓存只在 Header 切换时才刷新是成本最低的优化手段。3.2 RecyclerView 的滚动监听与首帧问题监听滚动用的是 RecyclerView.addOnScrollListener在 onScrolled 回调里调用 update()。这里要注意一个新手容易忽略的问题onScrolled 只会在滚动发生时回调如果列表初始还没有滚动悬浮 View 会处于空白状态。所以 Helper 在 attach() 时要先手动调一次 update()让悬浮头在第一时间显示第一个分组的数据。fun attach() { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { update() } }) // 首帧强制刷新一次避免悬浮头空白 update() }另外一个细节是 addOnScrollListener 可以被调用多次如果项目里已经有一个监听器在做别的逻辑完全不用合并再 add 一次即可互不影响。3.3 完整使用示例封装好 Helper 后在 Activity 或 Fragment 里使用很简单只需要把悬浮 View 的内容更新逻辑通过回调传进去class MainActivity : AppCompatActivity() { private lateinit var stickyHelper: StickyHeaderHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val recyclerView findViewByIdRecyclerView(R.id.recyclerView) val floatingHeader findViewByIdLinearLayout(R.id.floatingHeader) val tvFloatingTitle findViewByIdTextView(R.id.tvFloatingTitle) val data buildData() recyclerView.layoutManager LinearLayoutManager(this) recyclerView.adapter StickyAdapter(data) stickyHelper StickyHeaderHelper( recyclerView recyclerView, onHeaderChanged { item - tvFloatingTitle.text item.title } ) stickyHelper.attach() } private fun buildData(): MutableListListItem { val list mutableListOfListItem() for (group in 0 until 5) { list.add( ListItem( type StickyAdapter.TYPE_HEADER, groupId group-$group, title 第 ${group 1} 组, content null ) ) repeat(6) { index - list.add( ListItem( type StickyAdapter.TYPE_CONTENT, groupId group-$group, title 内容 ${group 1}-${index 1}, content 备用描述 ) ) } } return list } }到这里一个完整的顶部悬浮效果就已经能跑了。悬浮 View 固定在列表顶部随着滚动不断更新分组标题。整个实现没有引入任何三方库依赖只有 RecyclerView 本身。3.4 进阶让悬浮头跟着 Header 做推动过渡如果你觉得切换太生硬想做出“列表里的 Header 把悬浮头顶一下再落下”的过渡动画也可以在 update() 里额外算一个浮动偏移量。思路是拿到第一个可见 Header 的 View计算它相对于悬浮窗口的位置fun updateWithOffset() { update() val lm recyclerView.layoutManager as? LinearLayoutManager ?: return if (currentHeaderPos -1) return val headerView lm.findViewByPosition(currentHeaderPos) ?: return val floatHeight floatingHeader.height val offset (headerView.bottom - floatHeight).coerceAtLeast(0) floatingHeader.translationY offset.toFloat() }当 Header 还没滚到顶部区域时bottom 很大offset 为正悬浮头会被向下推一段距离当 Header 持续上滚bottom 越来越小悬浮头又慢慢回到顶部。这个公式其实就是把“悬浮头当前应该展示的位置”从 Header 的剩余可见高度中换算出来。但说实话这种推动动画在业务项目里用得不多而且用户可能觉得突兀我建议默认先做固定顶部等视觉设计明确要求了再加。4. 踩坑实录从首帧错位到数据刷新4.1 RecyclerView 首帧悬浮头没有数据现象页面刚打开悬浮头空白手指一滑才显示出来。原因很简单onScrolled 不会在初始状态触发而我们又没在布局完成后主动刷一次。解决办法就是在 attach() 里手动调用一次 update()。除此之外如果悬浮头和列表是在同一个 onCreate 里创建稍微注意一下 RecyclerView 的 layoutManager 是不是已经赋值Helper 里我用了 layoutManager as? LinearLayoutManager如果是 null 会悄悄 return首帧也会空白。这种静默失败不好排查建议在 Helper 里加一个日志或者断言开发阶段直接暴露问题。4.2 数据刷新后悬浮头不同步场景列表数据发生了整体更新比如下拉刷新后分组从 5 组变成 3 组悬浮头还停留在原来的分组标题。原因悬浮头只在滚动触发时更新notifyDataSetChanged 不会自动引发滚动回调。解决办法是在数据刷新后重新调用 stickyHelper.update()。如果你的数据更新用了 DiffUtil由于 DiffUtil 触发的是局部刷新悬浮头也更需要手动同步一次因为它根本不在 Adapter 管理范围内。实际操作中我习惯把 update() 做成 public 方法数据刷新和滚动监听都可以调用它保证状态一致。4.3 快速滚动时悬浮头闪烁快速滑动列表时悬浮头偶尔出现文字闪动或者背景短暂消失。这个问题通常有两类原因一类是更新悬浮头的回调里做了耗时操作比如加载网络图片、执行复杂计算在滚动高频触发时产生卡顿另一类是回调节流判断有误比如用 groupId 判断而不是 position 判断当两个 groupId 相同时重复刷新。这里要强调一下用 position 做节流比用 groupId 更可靠。我自己的代码里统一用 currentHeaderPos 缓存不比较字符串效率更高也不容易判断错。4.4 悬浮头点击事件失效有的需求要点击悬浮头跳转到对应分组顶部。如果你给悬浮 View 加了 OnClickListener 却一直不生效先看看悬浮 View 是不是被其它 View 盖住了或者悬浮 View 本身有 elevation 但父容器是一个 LinearLayout顺序不对导致层级压住。我遇到过一个更隐蔽的问题悬浮 View 的位置在 FrameLayout 中间RecyclerView 滚动起来后某些 RecyclerView 子项在悬浮头上方绘制导致悬浮头点击区域被抢占。这个可以通过给悬浮 View 设置一个比较大的 elevation 解决或者检查 RecyclerView 是否也设置了 elevation 且层级更高。4.5 多布局 getItemViewType 用错导致崩溃前面提到过onCreateViewHolder 里要根据 viewType 创建不同的 ViewHolder。如果你在 getItemViewType 里写错了条件比如返回了同一个常量那 onBindViewHolder 里强转 ViewHolder 时就会 ClassCastException。这个问题看起来低级但在数据模型重构时特别容易引入因为 IDE 不会提示逻辑错误。我的习惯是给 type 常量加上语义化的名字并且在 Adapter 的 onCreateViewHolder 里写 else 分支而不是 else if这样即使 type 传错也能兜底到 Content 类型不至于直接崩溃。下面整理一份问题速查表方便开发时快速定位问题可能原因解决思路悬浮头首帧空白初始没有触发滚动回调attach 时手动调用 update()数据刷新后悬浮头不对notifyDataSetChanged 不触发滚动刷新数据后回调 update()快速滚动闪烁悬浮头更新做了耗时操作只更新文字/轻量视图避免耗时任务悬浮头点击失灵层级遮挡或 elevation 不足提高悬浮 View 层级检查点击区域ClassCastExceptiongetItemViewType 返回错误类型检查 Type 常量完善 onCreateViewHolder 分支滚动到末尾悬浮头消失最后一个 Header 没有足够内容顶上可加尾部占位或确认产品预期5. 进阶扩展与替代方案5.1 ItemDecoration 实现对照如果你的悬浮头真的只有一行文字或者产品要求性能做到极致那 ItemDecoration 方案也值得掌握。核心思路是在 onDrawOver 阶段用 Canvas 在悬浮位置绘制一个背景矩形和文字。关键代码大致长这样class StickyItemDecoration( private val getGroupId: (Int) - Int, private val getHeaderText: (Int) - String ) : RecyclerView.ItemDecoration() { private val paint Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize 14f color Color.BLACK } private val bgPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.LTGRAY } override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { super.onDrawOver(c, parent, state) val lm parent.layoutManager as? LinearLayoutManager ?: return val firstVisible lm.findFirstVisibleItemPosition() if (firstVisible RecyclerView.NO_POSITION) return var headerPos firstVisible while (headerPos 0 getGroupId(headerPos) getGroupId(headerPos - 1)) { headerPos-- } val headerView lm.findViewByPosition(headerPos) ?: return val offset headerView.top.coerceAtMost(0) c.save() c.translate(0f, offset.toFloat()) c.drawRect(0f, 0f, parent.width.toFloat(), 48.dp, bgPaint) c.drawText(getHeaderText(headerPos), 16.dp, 32.dp, paint) c.restore() } }这个方案的优势是不需要额外的悬浮 View布局和内存都更省。但缺点也很直接所有视觉效果都要用 Paint 实现碰到圆角、渐变、图标、按钮就非常吃力点击事件还得自己算坐标命中区域处理起来比 View 方案繁琐不少。我的建议是优先悬浮 View 方案除非你对 Canvas 绘制非常有把握或者确实对性能有极端要求。5.2 不同高度 Header 的适配问题上面我的 Header 高度是固定的 48dp实际项目里可能会出现 A 组 Header 高 48dp、B 组 Header 高 72dp 的情况。如果悬浮 View 只写死一个高度要么过高遮挡内容要么过低切换时露白。处理思路有两个一个是和设计沟通统一所有分组头高度这是最省事的另一个是动态测量 Header View 的高度在 update() 里读取当前 Header 的 height然后同步修改悬浮 View 的高度和位置。第二种方案需要注意悬浮 View 的布局参数修改后要调用 requestLayout()否则高度不会实时生效。另外高度频繁变化可能会造成底部 Content 跳动要配合动画才能平滑。5.3 嵌套滚动场景要注意什么如果页面整体包了一层 NestedScrollViewRecyclerView 在里面再滚动悬浮头的位置计算就会出问题因为 RecyclerView 的滚动状态是嵌套滚动的一部分悬浮 View 挂在 RecyclerView 的父容器里父容器可能也跟着滚上去了。遇到这种结构我一般直接建议抛弃 NestedScrollView因为 RecyclerView 本身就支持滚动外层套 NestedScrollView 会把列表复用机制废掉性能也会打折。如果业务上确实需要页面级整页滚动更合理的做法是用 CoordinatorLayout 加 Behavior 自定义处理这个复杂度已经超出当前主题了能避免就避免。5.4 引入三方库 StickyHeaderDecoration 的取舍三方库方案建议在开发初期做原型验证时用因为它开箱即用几行代码就能看到吸顶效果。但如果你的需求里悬浮头不止是文字需要放搜索框、切换按钮、进度条或者要跟随滚动做自定义动画那三方库反而不如自己写的这几十行代码灵活。另外三方库通常附带自己的 ItemDecoration 实现遇到 RecyclerView 布局嵌套、GridLayoutManager、多 ViewType 场景时都可能出现兼容问题。我的态度是这种功能不算复杂自己维护一小段 Helper 比依赖一个库更安心至少不会出现版本升级后某个重绘接口突然失效的问题。最后分享一个实际经验做这个功能我前后改过三版。第一版用 ItemDecoration因为网上教程多第二版用三方库接入快但定制难第三版才沉淀成现在这套悬浮 View 方案代码量最少反而最稳。我后来仔细复盘明白了一个道理RecyclerView 的悬浮效果核心不是绘制而是数据状态管理。只要想清楚“当前第一个可见位置对应哪个分组”再配合真实的 View 去承载展示和交互这个需求就完成了七八成。剩下的无非是滚动动画、高度适配、刷新同步这些边角。所以如果你正在调研这个功能别被我之前踩坑的过程吓到照着这篇的思路先把“向前找 Header”的主链路跑通再根据业务需要逐步加特效整体开发节奏会顺畅很多。最后再顺手记录一个小提示悬浮 View 的滚动监听回调在 RecyclerView 快速 fling 时频率会很高update() 里务必保持轻量只做查位置、比较、setText 这类轻操作任何涉及网络、图片加载的逻辑都不要往这里塞否则列表流畅度一定会被打回原形。
阅读完成 · 觉得有帮助?