Android嵌套滚动

Android Design包通过提供NestedScrollingChild和NestedScrollingParent来帮助开发实现嵌套滑动效果

NestedScrollingChild与NestedScrollingParent关系简述

NestedScrollingChild NestedScrollingParent 备注
dispatchNestedScroll onNestedScroll 分发嵌套滑动事件,在子View滑动处理完之后调用, unconsumed表示未被子View滚动消费的距离, consumed表示被子View消费的滚动距离
startNestedScroll onStartNestedScroll 前者的调用会触发后者的调用,然后后者的返回值将决定后续的嵌套滑动事件是否能传递给父View,如果返回false,父View将不处理嵌套滑动事件,一般前者的返回值即后者的返回值
onNestedScrollAccepted 如果onStartNestedScroll返回true,则回调此方法
stopNestedScroll onStopNestedScroll
dispatchNestedPreScroll onNestedPreScroll 分发预嵌套滑动事件,在子View滑动处理之前调用, 通过consumed数组得到NestedScrollingParent消耗掉的滚动距离
dispatchNestedFling onNestedFling
dispatchNestedPreFling onNestedPreFling
getNestedScrollAxes 获得滑动方向,没有回调,为主动调用的方法

举例说明: SwipeRefreshLayout && RecyclerView嵌套

本例子中,SwipeRefreshLayout为NestedScrollingParent,而RecyclerView为NestedScrollingChild

根据触摸事件分发机制,ACTION_DOWN首先会来到SwipeRefreshLayout#onInterceptTouchEvent, 而SwipeRefreshLayout未做拦截,因此来到RecyclerView#onTouchEvent, 而RecyclerView#onTouchEvent针对ACTION_DOWN返回true;之后的所有事件都会传递到RecyclerView中,之后我们看下RecyclerView中对ACTION_MOVE事件的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

case MotionEvent.ACTION_MOVE: {
// ...

if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
dx -= mScrollConsumed[0]; // 得到parent消费滚动后的剩余y距离
dy -= mScrollConsumed[1]; // 得到parent消费滚动后的剩余x距离
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
}
// ...

if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];

if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;




关注这里的dispatchNestedPreScrollscrollByInternal方法, dispatchNestedPreScroll最终会触发SwipeRefreshLayout#onNestedPreScroll方法,scrollByInternal会调用dispatchNestedScroll方法,最终也来到SwipeRefreshLayout#onNestedScroll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

@Override
public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
final int dxUnconsumed, final int dyUnconsumed) {
//将nestedScroll传递给Parent
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
mParentOffsetInWindow);

final int dy = dyUnconsumed + mParentOffsetInWindow[1];

if (dy < 0 && !canChildScrollUp()) { //注意canChildScrollUp方法
mTotalUnconsumed += Math.abs(dy);

moveSpinner(mTotalUnconsumed); //处理refreshView的滑动
}
}

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
//只有当refreshView已经出现在屏幕中,并且手指往上移动才会调用下面的代码
if (dy > 0 && mTotalUnconsumed > 0) {
if (dy > mTotalUnconsumed) {
consumed[1] = dy - (int) mTotalUnconsumed;
mTotalUnconsumed = 0;
} else {
mTotalUnconsumed -= dy;
consumed[1] = dy; //消耗掉的距离, 这里回返回给RecyclerView
}
moveSpinner(mTotalUnconsumed);//处理refreshView的滑动
}

// refreshView移除屏幕
if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0
&& Math.abs(dy - consumed[1]) > 0) {
mCircleView.setVisibility(View.GONE);
}

// 将nestedPreScroll传递到Parent去(本文可以忽略)
final int[] parentConsumed = mParentScrollConsumed;
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
consumed[0] += parentConsumed[0];
consumed[1] += parentConsumed[1];
}
}

嵌套滚动时一个核心问题是当手指滑动时,这个滑动的距离由谁消费?为了解决这个问题,NestedScrollingParent引入了consumed这个概念,通过一个consumed数组的引用,可以告知上层View消费掉了多少距离,而子View则可以根据这个消费掉的距离,以及滑动的总距离,来处理自己的滑动距离

在本例子中,手指移动的距离 = refreshView滑动的距离 + RecyclerView滑动的距离

如何在代码中实现嵌套滚动效果

对一个NestedScrollingChild首先调用startNestedScroll,之后调用dispatchNestedScroll即可

1
2
3
4
5
6
7
8
recyclerView.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
val arr = IntArray(2)
recyclerView.dispatchNestedPreScroll(0, offsetY, arr, null)
val dyUnconsumed = offsetY - arr[1]
if (dyUnconsumed != 0) {
recyclerView.scrollBy(0, dyUnconsumed)
}