前言
现在,我们要实现的效果是------可折叠的标题栏。它有很多日常的场景,例如知乎的个人主页:往上滑动时标题栏会先收起,到顶部后内容再开始滚动。

其实,这种折叠效果官方早就通过 CoordinatorLayout 提供了成熟的方案(具体可以看我的这篇博客:可折叠式标题栏),但为了深刻理解底层的嵌套机制,我们还是来手搓一个自定义容器。
传统事件分发为什么不行?
这涉及到了 NestedScrolling(嵌套滑动)协议。在了解它之前,我们先来思考一个问题:
如果在传统的事件分发机制(dispatchTouchEvent, onInterceptTouchEvent)中实现这个效果,会发生什么?
手指向上滑时,我们会在父容器中拦截并消费事件,让主页区(头部)先收起。等主页区完全收起后,再把事件交给子 View 消费,RecyclerView 列表开始滚动。
但你会发现一个致命的问题,滑动不是连贯的。为了将事件交给子 View,手指必须要先离开屏幕,再向上滑。这是因为一旦父容器拦截了事件,那么在这个手势周期中,后续的事件都不会传递给子 View。同样,如果子 View 消费了事件,父容器也无法干预,事件的处理是单向且独占的。
传统的机制根本无法实现同一个手势中无缝更换消费对象。所以就有了 NestedScrolling 协议,它的核心是由子 View 来驱动滑动协调。
嵌套滑动的工作流与协议机制
这套机制共有两个核心接口,NestedScrollingChild3 和 NestedScrollingParent3,父子视图通过它们来实现滑动中的沟通交流。
NestedScrollingChild3:支持滑动的子控件(如 RecyclerView、NestedScrollView),它会拿到触摸事件,然后按照协议主动去询问父 View。
NestedScrollingParent3:是外层的父容器(如 CoordinatorLayout、NestedScrollView、以及等下我们要写的自定义 View),它会接收子 View 的请求,决定自身是否要消费滑动距离。
具体的接口拆解会在代码中展示,这里先理清流程。我们以一次手势的完整周期,来讲解嵌套滑动的工作机制:
-
ACTION_DOWN当手指按下时,子 View 会收到
ACTION_DOWN事件,然后就会调用startNestedScroll()沿着 View 树向上找,找一个愿意配合滑动的父容器。如果找到会返回true,这表示连接建立成功。 -
ACTION_MOVE(滑动前)当手指移动时,子 View 会算出此次移动的距离(比如上滑 50px)。在自己要滑动之前,它会先调用
dispatchNestedPreScroll()询问父容器:我要滑 50px,你要不要拿一部分去消费?父容器可能会全部消费掉,也可能只消费一部分,例如消费 30px 用来折叠头部。
-
子 View 自身消费
子 View 会得到父容器消费的距离 30px 以及剩下的距离 20px,然后将这剩下的 20px 距离用于自身列表的滑动。
-
ACTION_MOVE(滑动后)如果子 View 滑到顶/底了,但滑动距离还有剩(比如还剩下 10px),它会调用
dispatchNestedScroll()再次询问父容器:我滑完后还剩下 10px,你要不要拿去用?(比如父容器可以拿去触发下拉刷新或展开头部) -
ACTION_UP / CANCEL手指抬起或是事件被取消,子 View 会调用
stopNestedScroll()通知父容器,此次滑动结束了,可以清理一下状态。
实战:打造 MyCollapsingLayout
接下来,我们来实现一个自定义的拦截父容器 MyCollapsingLayout,仿造知乎的主页,实现折叠主页区、详情头吸顶以及列表滚动。
首先我们对着官方的 Material 的写法,给出 Demo 的布局。
这里有一个小细节,为了防止滑动到边缘 时出现半透明的泛光效果,破坏嵌套滑动的视觉连贯性,我们在 RecyclerView 中加上了 android:overScrollMode="never"。
如果保留默认的泛光效果,当列表滑到顶时会触发拉伸动画,阻碍事件马上回传给父容器,导致嵌套滑动卡顿。
xml
<?xml version="1.0" encoding="utf-8"?>
<!--
结构
MyCollapsingLayout
├─ contentList RecyclerView
├─ profileSection 主页区(上滑整块收起)
├─ stickyHeader 详情吸顶头(回答 / 动态 / 文章)
└─ toolbar 固定标题栏(最后声明,盖在最上层)
-->
<com.example.demoview.widget.MyCollapsingLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/collapsingRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="32dp"
android:background="#F6F6F6">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contentList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never" />
<!-- 可折叠的主页区 -->
<LinearLayout
android:id="@+id/profileSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:layout_width="64dp"
android:layout_height="64dp"
android:background="#FF6D00" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="雨白"
android:textColor="#121212"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="学习Android中的嵌套滚动"
android:textColor="#8590A6"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="主页简介:上滑先收这段主页,收完后下方「回答 / 动态 / 文章」吸顶,再滚列表。"
android:textColor="#444444"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="128\n关注"
android:textColor="#121212"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="3\n关注者"
android:textColor="#121212"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="56\n赞同"
android:textColor="#121212"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 详情固定头:主页收完后吸在标题栏下方 -->
<LinearLayout
android:id="@+id/stickyHeader"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="#FFFFFF"
android:elevation="2dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="16dp"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="回答"
android:textColor="#121212"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="动态"
android:textColor="#8590A6"
android:textSize="15sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="文章"
android:textColor="#8590A6"
android:textSize="15sp" />
</LinearLayout>
<!-- 固定标题栏:始终钉在顶部;折叠进度高时显示用户名 -->
<FrameLayout
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#FFFFFF"
android:elevation="4dp"
android:paddingVertical="16dp"
android:paddingTop="8dp">
<TextView
android:id="@+id/toolbarTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:alpha="0"
android:text="雨白"
android:textColor="#121212"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="←"
android:textColor="#121212"
android:textSize="20sp" />
</FrameLayout>
</com.example.demoview.widget.MyCollapsingLayout>
接着来一步步实现这个自定义父容器。
骨架搭建:实现接口
首先实现 NestedScrollingParent3 接口
kotlin
class MyCollapsingLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
NestedScrollingParent3 { // 实现NestedScrollingParent3接口
init {
// 裁剪子 View 超出父容器顶部的部分,避免画到状态栏外
clipChildren = true
clipToPadding = true
}
// 后续方法逐步填充...
}
测量逻辑:确定列表的真实高度
然后是测量代码。这里有个很关键的细节:列表的高度必须是折叠后的屏幕剩余空间,否则头部折叠完成后,列表底部会有一片空白。
这也意味着,我们的 MyCollapsingLayout 最外层应该是一个有确切高度的容器。不应该包裹在 ScrollView 这种高度不确定(UNSPECIFIED)的容器中。
kotlin
private lateinit var toolbar: View // 顶部固定标题栏
private lateinit var profile: View // 主页信息区
private lateinit var sticky: View // 吸附悬浮头
private lateinit var scrollChild: View // 底部滑动列表
// 记录各个区域的高度
private var toolbarHeight = 0
private var profileHeight = 0
private var stickyHeight = 0
// 可折叠的最大距离
private var maxCollapse = 0
override fun onFinishInflate() {
super.onFinishInflate()
// 布局加载后,找到对应的子控件
toolbar = findViewById(R.id.toolbar)
?: error("can't find @id/toolbar")
profile = findViewById(R.id.profileSection)
?: error("can't find @id/profileSection")
sticky = findViewById(R.id.stickyHeader)
?: error("can't find @id/stickyHeader")
scrollChild = findViewById(R.id.contentList)
?: error("can't find @id/contentList")
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 获取父容器给我们的确切空间
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
// 保存自身尺寸
setMeasuredDimension(width, height)
// 测试规格:宽度必须铺满(EXACTLY),高度由子View决定(无限制)
val exactWidth = MeasureSpec
.makeMeasureSpec(width, MeasureSpec.EXACTLY)
val wrapHeight = MeasureSpec
.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
// 测量子 View
toolbar.measure(exactWidth, wrapHeight)
profile.measure(exactWidth, wrapHeight)
sticky.measure(exactWidth, wrapHeight)
// 获取各组件的测量高度
toolbarHeight = toolbar.measuredHeight
profileHeight = profile.measuredHeight
stickyHeight = sticky.measuredHeight
// 最大折叠距离就是主页区的高度
maxCollapse = profileHeight
// 测量底部滑动列表
// 列表的高度 = 总高度 - (永远可见的 Toolbar) - (永远可见的吸顶头)
val listHeight = (height - toolbarHeight - stickyHeight).coerceAtLeast(0)
scrollChild.measure(
exactWidth,
MeasureSpec.makeMeasureSpec(listHeight, MeasureSpec.EXACTLY)
)
}
布局摆放与滑动位移
然后是布局摆放,我们要按照完全展开的坐标来摆放 。因为我们的折叠是通过 translationY 偏移来实现的,而不是通过改变 height 高度。
这样就不会因为滑动时的频繁重新测量、重新布局(
requestLayout),导致页面抽搐和列表高度抖动。
kotlin
// 当前已经折叠的距离
private var collapseOffset = 0
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
val w = right - left
// 按完全展开坐标摆放
toolbar.layout(0, 0, w, toolbarHeight)
profile.layout(0, toolbarHeight, w, toolbarHeight + profileHeight)
sticky.layout(
0,
toolbarHeight + profileHeight,
w,
toolbarHeight + profileHeight + stickyHeight
)
val listTop = toolbarHeight + profileHeight + stickyHeight
scrollChild.layout(0, listTop, w, listTop + scrollChild.measuredHeight)
// 修改绘制层级 (Z轴)
scrollChild.elevation = 0f // 列表在最下层
profile.elevation = 1f
sticky.elevation = 2f
toolbar.elevation = 3f // Toolbar在最高层
// 初始应用当前折叠偏移,横竖屏切换等场景也适用
applyCollapseOffset(collapseOffset, notify = false)
}
// 折叠进度,0.0 ~ 1.0,方便外部调整标题栏的透明度
var onCollapseProgressChanged: ((Float) -> Unit)? = null
/**
* 应用折叠偏移
* @param offset 折叠偏移距离
* @param notify 是否通知回调
*/
private fun applyCollapseOffset(offset: Int, notify: Boolean) {
// 0 表示完全展开,maxCollapse 表示完全折叠
collapseOffset = offset
// 往上推,所以是负值
val ty = -offset.toFloat()
// 三者同步上移:主页收走,吸顶头贴到标题栏下,列表跟上
profile.translationY = ty
sticky.translationY = ty
scrollChild.translationY = ty
// 计算进度回调,0f 是全展开,1f 是全收起
if (notify) {
val progress = if (maxCollapse == 0) 0f else offset.toFloat() / maxCollapse
onCollapseProgressChanged?.invoke(progress)
}
}
核心逻辑:拦截与消费滑动事件
接着来看看嵌套滑动中最核心的几个回调,首先是参与滑动前的校验建立。
这里官方提供了一个帮助类
NestedScrollingParentHelper,让我们可以快速实现一些固定繁琐的逻辑。
kotlin
// -------------------------------------------------------------------------
// NestedScrollingParent3
// -------------------------------------------------------------------------
private val parentHelper = NestedScrollingParentHelper(this)
/**
* 滚动开始,决定是否参与此次嵌套滑动(建立连接)
* @param child 触发滚动的子 View
* @param target 真正产生滚动的子 View,一般等于 child
* @param axes 滑动方向,水平或垂直,位掩码,也可能同时是两者
* @param type 滑动类型,手指触摸屏幕或是惯性滚动
*/
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
// 只响应垂直方向的嵌套滑动
return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}
/**
* 参与此次嵌套滑动,完成初始化
*/
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
// 保存滑动方向状态
parentHelper.onNestedScrollAccepted(child, target, axes, type)
}
然后是 onNestedPreScroll() 和 onNestedScroll()。
注意:我们在给
consumed[1]赋值时,它必须等于实际消耗的像素值,否则可能导致子 View 收到错误的剩余距离,发生滑动错乱。
kotlin
/**
* 子 View 滑动前,父容器先拦截
* @param target 真正产生滚动的子 View
* @param dx 子 View 本次想要滚动的水平偏移,>0 表示手指向右移动
* @param dy 子 View 本次想要滚动的垂直偏移,>0 表示手指向上移动
* @param consumed 分别表示水平和垂直方向上父消耗的偏移量
* @param type 滑动类型,手指触摸屏幕或是惯性滚动
*/
override fun onNestedPreScroll(
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
// 如果手指向上滑,并且主页还未完全收起
if (dy > 0 && collapseOffset < maxCollapse) {
// 消费掉一段距离来折叠头部
// 记录到 consumed[1] 中,明确告诉子 View 我们消费了多少垂直像素
consumed[1] = applyCollapseDelta(dy)
}
}
/**
* 子 View 滑到顶了,把剩下的距离交给父容器
* @param target 真正产生滚动的子 View
* @param dxConsumed 子 View 消费的水平偏移量
* @param dyConsumed 子 View 消费的垂直偏移量
* @param dxUnconsumed 子 View 剩余的水平偏移量
* @param dyUnconsumed 子 View 剩余的垂直偏移量
* @param type 滑动类型,手指触摸屏幕或是惯性滚动
* @param consumed 分别表示水平和垂直方向上父消耗的偏移量
*/
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
// 如果手指向下滑,列表已经滑到顶了(dyUnconsumed < 0),且主页还没有完全展开
if (dyUnconsumed < 0 && collapseOffset > 0) {
// 消费掉一段距离来展开主页
consumed[1] += applyCollapseDelta(dyUnconsumed)
}
}
/**
* 接收滑动的变化量,应用偏移,返回真实消费的像素值
* @param delta >0 继续收起;<0 展开
* @return 实际生效的 delta
*/
private fun applyCollapseDelta(delta: Int): Int {
if (maxCollapse == 0 || delta == 0) return 0
val old = collapseOffset
val newOffset = (collapseOffset + delta).coerceIn(0, maxCollapse)
val actual = newOffset - old // 计算出真实生效的像素差
if (actual == 0) return 0
applyCollapseOffset(newOffset, notify = true)
return actual // 返回真实消耗量给上层的 consumed 数组
}
Fling 的平滑转换与杂项补全
最后补全剩下的旧版 API 兼容代码和 Fling 方法。
我们的 Fling (惯性滑动) 方法直接返回了 false,这是因为在 V2/V3 协议中,RecyclerView 的惯性滑动会被底层转换为 TYPE_NON_TOUCH 的 Scroll 事件不断派发。
我们无需去实现,直接用上面写好的 onNestedPreScroll 就能完美无缝衔接,这也是 V2 版本解决 V1 版本痛点的核心体现。
kotlin
/**
* 获取滑动方向
*/
override fun getNestedScrollAxes(): Int = parentHelper.nestedScrollAxes
/**
* 滑动结束
*/
override fun onStopNestedScroll(target: View, type: Int) {
parentHelper.onStopNestedScroll(target, type)
}
// --- 下面全是旧版 API 兼容代码,直接带上 TYPE_TOUCH 去调用 V3 方法 ---
override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean =
onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH)
override fun onNestedScrollAccepted(child: View, target: View, axes: Int) =
onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH)
override fun onStopNestedScroll(target: View) =
onStopNestedScroll(target, ViewCompat.TYPE_TOUCH)
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) =
onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH)
override fun onNestedScroll(
target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int
) = onNestedScroll(
target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
ViewCompat.TYPE_TOUCH, IntArray(2)
)
override fun onNestedScroll(
target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
dyUnconsumed: Int, type: Int
) = onNestedScroll(
target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, IntArray(2)
)
// 忽略 Fling 的手动拦截,依赖 V2/V3 底层的 TYPE_NON_TOUCH 派发机制来平滑转换
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float) = false
override fun onNestedFling(
target: View,
velocityX: Float,
velocityY: Float,
consumed: Boolean
) = false
/**
* 生成默认的 LayoutParams
*/
override fun generateDefaultLayoutParams(): LayoutParams {
return LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
最后是 Activity 跑起来的测试代码:
kotlin
class MyCollapsingDemoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my_collapsing_demo)
supportActionBar?.hide()
title = "仿知乎折叠主页"
val toolbarTitle = findViewById<TextView>(R.id.toolbarTitle)
val root = findViewById<MyCollapsingLayout>(R.id.collapsingRoot)
root.onCollapseProgressChanged = { progress ->
// 主页收得越多,标题栏用户名越明显
toolbarTitle.alpha = (progress * 1.2f).coerceIn(0f, 1f)
}
val list = findViewById<RecyclerView>(R.id.contentList)
list.layoutManager = LinearLayoutManager(this)
list.adapter = FeedAdapter(buildFakeFeed())
}
private fun buildFakeFeed(): List<FeedItem> {
return (1..40).map { index ->
FeedItem(
title = "回答 #$index:嵌套滚动里 dy 先给谁?",
body = "Parent 在 PreScroll 先收主页;主页收完后 Child 才滚列表。" +
"到顶后的下滑剩余量再在 NestedScroll 里展开主页。"
)
}
}
private data class FeedItem(val title: String, val body: String)
private class FeedAdapter(
private val items: List<FeedItem>
) : RecyclerView.Adapter<FeedAdapter.Holder>() {
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.tvItemTitle)
val body: TextView = view.findViewById(R.id.tvItemBody)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_zhihu_feed, parent, false)
return Holder(view)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = items[position]
holder.title.text = item.title
holder.body.text = item.body
}
override fun getItemCount(): Int = items.size
}
}
补充:协议的演进史
你看到代码中 NestedScrollingParent3 有个数字 3,其实这是官方演进的第三个版本,我们刚刚在解释 Fling 时也有提到,最后用一张表来总结这三个版本的进化史:
| 版本 | 核心痛点与改进 | 变化细节 |
|---|---|---|
| V1 | 痛点:惯性滑动 (Fling) 不连贯。 | Fling 是一次性丢给父或子的,无法像普通的 Scroll 那样按像素来分配。 |
| V2 | 改进:将 Fling 转化为普通滑动。 | 接口所有方法增加了 type 参数(TYPE_TOUCH 手指滑动, TYPE_NON_TOUCH 惯性滑动),让 Fling 也能完美联动。 |
| V3 | 改进:精确的滑动后 (PostScroll) 消费反馈。 | dispatchNestedScroll 增加了 consumed 数组参数,父容器兜底滑动后,能确切告诉子 View 自己到底消费了多少像素。 |