1. 背景
本文介绍 Android 嵌套滑动机制,不会过多的关注代码细节,而是聚焦于设计思想。通过本文,您可以了解到:
- 为什么需要 NestedScrolling 接口?
- 为什么需要 CoordinatorLayout?
- AppBarLayout.Behavior 复杂的继承关系。
2. 自定义View实现嵌套滑动的问题
2.1 需求:嵌套滑动
为了探究 NestedScrolling 与 CoordinatorLayout 的作用,我们先不使用它们,而是通过自定义View来实现嵌套滑动,看看会有什么问题。
如下图,我们期望滑动 RecyclerView 时,上方的 HeaderView 能够跟随滑动。

2.2 实现思路
- 自定义 ViewGroup,取名为 CustomParent,继承自 LinearLayout。
- 事件到来时,如果需要嵌套滑动,则让 CustomParent 拦截事件,并通过 scroll 将整体进行移动。
- 如果不需要嵌套滑动,则不拦截事件,让 RecyclerView 滑动。

2.3 实现代码
kotlin
class CustomParent @JvmOverloads constructor(mContext: Context, attributeSet: AttributeSet? = null, flag: Int = 0) : LinearLayout(mContext, attributeSet, flag) {
private val touchSlop = 6
private var startY = 0f
private val headerViewHeight: Int
get() = children.filterIsInstance<HeaderView>().first().measuredHeight
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
var intercepted = false
var dy = 0f
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
startY = ev.rawY
}
MotionEvent.ACTION_MOVE -> {
dy = startY - ev.rawY
if (abs(dy) > touchSlop) {
// 向上移动且未完全折叠、向下移动且未完全展开,拦截事件
intercepted = (dy > 0 && !isTotalFold()) || (dy < 0 && !isTotalExpanded())
}
startY = ev.rawY
}
}
return intercepted
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
startY = event.rawY
}
MotionEvent.ACTION_MOVE -> {
val dy = startY - event.rawY
scrollInBounds(dy.toInt())
startY = event.rawY
}
}
return super.onTouchEvent(event)
}
// HeaderView完全折叠
private fun isTotalFold(): Boolean {
return scrollY >= headerViewHeight
}
// HeaderView完全展开
private fun isTotalExpanded(): Boolean {
return scrollY == 0
}
// 限制范围地滑动
private fun scrollInBounds(y: Int): Int {
val targetScrollY = (scrollY + y).coerceIn(0, headerViewHeight)
val diffY = targetScrollY - scrollY
super.scrollBy(0, diffY)
return diffY
}
}
2.4 问题:受限于 Android 事件机制
观察视频后半段我们会发现,HeaderView 完全折叠后,我们手势持续往上滑,RecyclerView 并没有继续向上滚动。需要先松手,再次上滑 RecyclerView 才能上滑。

原因是,在 Android 事件体系中:
- Down/Move 事件 会确定一条 链表 ,这条链表就是 拦截事件的 View 及其祖先。
- 同一事件序列,后续所有事件 只能由 链表的上的View 接收,从而加快事件分发效率。
在向上滑动 RecyclerView 这个案例中:
- Down 事件 由 RecyclerView 拦截,链表如下,链表上的所有 View 都可以拦截后续事件。

- Move 事件 由 CustomParent 拦截,从而进行 scroll。拦截后链表状态如下,RecyclerView 已不在链表中,后续无法拦截事件,所以折叠后的向上滑动事件 RecyclerView 无法接收。

3. NestedScrolling 接口诞生
3.1 问题分析
由于事件机制的限制,要想实现理想的效果,不能让父View拦截事件 。我们可以:子View拦截事件,但消费前先让父View先消费。
3.2 实现思路
- 定义一个 [接口] 。
- 父 View 实现这个接口,子 View 拦截事件后,找到实现了该接口的父 View,先让父 View 消费,剩余的滑动距离再由子 View 消费。
3.3 NestedScrollingParent 、NestedScrollingChild
- Android 已经帮我们定义好了相关的接口。
- 为了规范流程,参与嵌套滑动的 父View 和 子View 都有对应的接口,NestedScrollingParent、NestedScrollingChild。
- 接口中定义了许多方法,约束了嵌套滑动的流程:

3.4 RecyclerView 实现了 NestedScrollingChild
RecyclerView 默认实现了 NestedScrollingChild,伪代码如下:
java
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
@Override
public boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 1.寻找是否有实现了 NestedScrollingParent 的父View
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
}
case MotionEvent.ACTION_MOVE: {
// 2.如果有相关的父View,让它先消费
if (dispatchNestedPreScroll(...)) {
dx -= mReusableIntPair[0]; // mReusableIntPair 中存储了父View消费的距离
dy -= mReusableIntPair[1];
}
scrollByInternal(dx, dy);
}
}
}
boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
// 3.自身消费父View剩余的距离
scrollStep(x, y, mReusableIntPair);
// 4.如果还有剩,再让父View消费
dispatchNestedScroll(...);
}
}
3.5 CustomParent 实现 NestedScrollingParent
RecyclerView 已经实现了 NestedScrollingChild 接口,我们只需要让 CustomParent 实现 NestedScrollingParent3,即可解决折叠后无法滑动的问题。
kotlin
class CustomParent: LinearLayout(...) , NestedScrollingParent3 {
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean = true
/**
RecyclerView消费前,如果是 向上滑动、未完全折叠,我们先消费该距离
*/
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy > 0 && !isTotalFold()) {
val consumedY = scrollInBounds(dy)
consumed[1] = consumedY // 告诉子View消费了多少
}
}
/**
RecyclerView消费后,如果是 向下滑动、未完全展开,我们消费剩下的距离
*/
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
if (dyUnconsumed < 0 && !isTotalExpanded()) {
val consumedY = scrollInBounds(dyUnconsumed)
consumed[1] = consumedY // 告诉子View消费了多少
}
}
// ...
}
3.6 效果
现在,HeaderView 折叠后,RecyclerView 也能继续滑动了。

3.7 问题:父View耦合子View的逻辑
当前实现下,子View想要嵌套滑动,是依托父View的能力。这导致父View需要 耦合 很多子View的逻辑,有没有更加低耦合的方案实现嵌套滑动呢?
4. CoordinatorLayout
4.1 具备哪些能力?
官方提供的 CoordinatorLayout 组件提供了 嵌套滑动、解耦 的能力。除此之外,它还具备 子View可相互依赖 的能力。下面我们依次来看一下。
4.2 实现了 NestedScrollingParent
CoordinatorLayout 实现了 NestedScrollingParent 接口,使其具备 嵌套滑动 的能力。
注:上一节描述的,子 View 滑动前会寻找实现 NestedScrollingParent 的接口,先让父 View 消费。

4.3 任务委托给 Behavior
4.3.1 原理
不仅仅是嵌套滑动,CoordinatorLayout 会先将大部分任务 委托 给子 View 的 Behavior 处理。如果没有子 View 处理,才由自身处理。从而进行 解耦。

想要处理特定任务的子 View,可以以 插件化 的方式为自己 设置 Behavior,并重写相关方法。

4.3.2 源码分析:嵌套滑动分发
这里拿嵌套滑动任务的委托来体现源码,其他类型的任务是差不多的。
- 让所有 Behavior 都消费嵌套滑动事件。
- 返回消费的最多的 Behavior 的消费值。
java
// CoordinatorLayout.java
@Override
public void onNestedPreScroll(View target , int dx , int dy , int[] consumed , int type) {
// ...
int xConsumed = 0 ;
int yConsumed = 0 ;
final int childCount = getChildCount() ;
for (int i = 0 ; i < childCount ; i++) {
final View view = getChildAt(i) ;
final LayoutParams lp = (LayoutParams) view.getLayoutParams() ;
final Behavior viewBehavior = lp.getBehavior() ;
if (viewBehavior != null) {
viewBehavior.onNestedPreScroll(this , view , target , dx , dy , mBehaviorConsumed , type) ;
xConsumed = dx > 0 ? Math.max(xConsumed , mBehaviorConsumed[0]) : Math.min(xConsumed , mBehaviorConsumed[0]) ;
yConsumed = dy > 0 ? Math.max(yConsumed , mBehaviorConsumed[1]) : Math.min(yConsumed , mBehaviorConsumed[1]) ;
}
}
consumed[0] = xConsumed ;
consumed[1] = yConsumed ;
// ...
}
4.4 子View可相互依赖
4.4.1 CoordinatorLayout 默认布局方式
CoordinatorLayout 的默认布局与 FrameLayout 一致,从左上方开始布局 ,子 View 会有 重叠 的效果,如下图。

如果想让子 View 依赖另一个子 View,可以继承 Behavior 的 layoutDependsOn 和 onDependentViewChanged 方法。
4.4.2 ScrollingViewBehavior
AppBarLayout.ScrollingViewBehavior 是一个很好的例子来体现子 View 间的依赖关系。
4.4.2.1 使用场景:Rv 紧贴 Header
- 现在想实现 RecyclerView 紧贴于 Header 下方。
- 首先,在 Header 外头 包一层 AppBarLayout。
- 然后为 RecyclerView 设置 ScrollingViewBehavior。
xml
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<HeaderView
android:id="@+id/header_view"
android:layout_width="match_parent"
android:layout_height="100dp"
/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior = "@string/appbar_scrolling_view_behavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
- 即可实现 RecyclerView 紧贴 Header 下方,即使 Header 移动时,RecyclerView 也会跟着移动。

4.4.2.2 源码分析:ScrollingViewBehavior
我们来看看 ScrollingViewBehavior 的核心源码:
java
public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {
@Override
public boolean layoutDependsOn(CoordinatorLayout parent , View child , View dependency) {
// 1.依赖 AppBarLayout
return dependency instanceof AppBarLayout ;
}
@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent , @NonNull View child , @NonNull View dependency) {
// 2.AppBarLayout变化时,调整自身布局位置,紧贴 AppBarLayout
offsetChildAsNeeded(child , dependency) ;
}
private void offsetChildAsNeeded(@NonNull View child , @NonNull View dependency) {
ViewCompat.offsetTopAndBottom(
child,
dependency.getBottom() - child.getTop() // ...
);
}
}
为什么 RecyclerView 的大小会超出屏幕?
答:因为 RecyclerView 的高度是 Match_Parent,也就是对其 CoordinatorLayout 的高度,虽然 ScrollingViewBehavior 改变了 RecyclerView 的布局位置,但并没有改变它的大小。
4.4.3 源码分析:依赖监听原理
上一小节中体现了 CoordinatorLayout 支持子 View 相互依赖的能力,这一小节看看它是如何实现的。
绘制后 ,判断 被依赖View 的 绘制区域 是否改变,如果改变了,通知依赖View。
java
class CoordinatorLayout {
private OnPreDrawListener mOnPreDrawListener ;
@Override
public void onAttachedToWindow() {
...
final ViewTreeObserver vto = getViewTreeObserver() ;
vto.addOnPreDrawListener(mOnPreDrawListener) ;
}
class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
@Override
public boolean onPreDraw() {
onChildViewsChanged(EVENT_PRE_DRAW) ;
return true ;
}
}
final void onChildViewsChanged(...) {
...
final int childCount = mDependencySortedChildren.size() ;
// 1.遍历 被依赖View
for (int i = 0 ; i < childCount ; i++) {
final View child = mDependencySortedChildren.get(i) ;
// 2.判断绘制区域是否改变
getChildRect(child , true , drawRect) ;
getLastChildRect(child , lastDrawRect) ;
if (lastDrawRect.equals(drawRect)) {
continue;
}
// 3.如果变了,通知依赖的 View
for (int j = i + 1 ; j < childCount ; j++) {
final View checkChild = mDependencySortedChildren.get(j) ;
final Behavior b = checkLp.getBehavior() ;
if (b != null && b.layoutDependsOn(this , checkChild , child)) {
...
b.onDependentViewChanged(this , checkChild , child) ;
}
}
}
}
}
5. AppBarLayout
5.1 具备哪些能力?
我们先按下面的方式实现 xml,看看效果。
xml
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
...
android:text="ViewInAppbar - scroll"
app:layout_scrollFlags= "scroll"
/>
<TextView
...
android:text="ViewInAppbar - snap"
app:layout_scrollFlags= "snap"
/>
</com.google.android.material.appbar.AppBarLayout>
<!--下面的 Behavior 就是上节讲到的 ScrollingViewBehavior -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior= "@string/appbar_scrolling_view_behavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

从视频中可以看出,AppBarLayout 包裹的内容具备以下能力:
- 随手势滑动
- Fling
- 部分吸顶
- 响应嵌套滑动(下拉 RecyclerView 时 AppbarLayout 也跟着下移)
5.2 实现思路
AppBarLayout 默认为自己设置了 AppbarLayout.Behavoir,从而实现上述功能。
注:上一节描述的,CoordinatorLayout 的子 View 可以通过 Behavior 来代理 CoordinatorLayout 的任务。这样做耦合度更低。
其继承关系如下:

可见还是比较复杂的,我们依次看看每个 Behavior 的职责。
5.3 ViewOffsetBehavior - 支持偏移、记住偏移量
假如一个 View 监听了事件,在 Move 事件 时,通过 offsetTopAndBottom 移动随手势移动自身。
但当 requestLayout 后 , View 会恢复正常的布局,因为 offsetTopAndBottom 并不会改变父 View 对其的布局方式。

ViewOffsetBehavior 为子 View 提供了 记住偏移量 的能力:
- 如下图,ViewOffsetHelper 存储了偏移量 offsetTop。
- 每次 setTopAndBottomOffset 时,都会更新 offsetTop。
- ViewOffsetBehavior 代理了自身的布局逻辑,会在自身布局后,再偏移 offsetTop。

为子 View 设置 ViewOffsetBehavior 后,调用 offsetTopAndBottom 后再刷新,也不会恢复原位。

5.4 HeaderBehavior - 支持手势、Fling
视频中可以看见,AppbarLayout 的内容会随手势移动,同时支持Fling。这个两个功能是在 HeaderBehavior 中实现的。从类图可以看出其实现:
- 代理 CoordinatorLayout 的 onInterceptTouchEvent,子 View 还能滑动时拦截事件。
- 代理 CoordinatorLayout 的 onTouchEvent。
- Move 事件时,利用 ViewOffsetBehavior 的能力偏移子 View。
- Up 事件时,OverScroller、VelocityTracker 的能力进行 fling。

5.5 AppBarLayout.BaseBehavior - 嵌套滑动、部分吸顶
BaseBehavior 则实现了剩余的两个能力:嵌套滑动、部分内容吸顶。我们分别介绍一下:
嵌套滑动:
代理 CoordinatorLayout 的 onNestedPreScroll/onNestedScroll。嵌套滑动事件到来时,利用 ViewOffsetBehavior 的能力偏移自身。

部分吸顶:
通过 限制滑动范围 来实现。在滑动前,会调用 AppBarLayout.getDownNestedScrollRange 方法,获取可滑动的范围,进行约束。
java
// BaseBehavior.java
@Override
public void onNestedScroll(...) {
// ...
consumed[1] =
scroll(coordinatorLayout , child , dyUnconsumed , -child.getDownNestedScrollRange() , 0) ;
}
AppBarLayout.getDownNestedScrollRange 的原理是将 第一个不可滑动的View 之前的所有 View 的高度,当作是滑动范围进行约束。

6. 常见问题
6.1 NestedScrollingParent、NestedScrollingParent2、NestedScrollingParent3 的区别?
在 3.3 节提到,接口的作用,除了能让子 View 找到参与嵌套滑动的父 View 外,还能约束嵌套滑动的流程。
接口的 版本演进 是为了增强功能,从而 规范流程。
例如,NestedScrollingParent2 在 NestedScrollingParent 的基础上,增加了 滑动类型 的概念。
约束了子 View 发起嵌套滑动时,需要告诉父 View 这是手势滑动还是 fling。
java
public class ViewCompat {
@IntDef({TYPE_TOUCH, TYPE_NON_TOUCH}) // TYPE_TOUCH:手势滑动,TYPE_NON_TOUCH:fling
public @interface NestedScrollType {}
}
public interface NestedScrollingParent {
boolean onStartNestedScroll(
@NonNull View child,
@NonNull View target,
@ScrollAxis int axes
);
}
public interface NestedScrollingParent2 extends NestedScrollingParent {
boolean onStartNestedScroll(
@NonNull View child,
@NonNull View target,
@ScrollAxis int axes,
@NestedScrollType int type // 与1.0版本的区别在此
);
}
在没有 NestedScrollingParent2 的时期,在需要判断滑动类型的场景,开发者可能不想使用 NestedScrollingParent,而是自己定义一套接口,这会造成不同组件的 兼容性问题。
所以后来官方就提供了 NestedScrollingParent 的 2.0 版本。
6.2 滑动冲突处理
AppBarLayout fling 的同时,手势滑动 RecyclerView,可能导致 嵌套滑动事件 和 fling 的滑动冲突。

我们可以继承 AppBarLayout.Behavior,重写 onStartNestedScroll 方法。嵌套滑动开始时,反射拿到 scroller,停止 AppBarLayout 的 fling。
