本系列将通过两篇文章:
嵌套滚动原理分析-- NestedScrolling
嵌套滚动原理分析--CoordinatorLayout
相信你一定会对嵌套滚动有更深的理解了:
嵌套滚动的必要条件有两条:
1.子View要去实现NestedScrollingChild接口
2.父类要去实现NestedScrollingParent接口,才有能完成嵌套滚动
上图实例:根布局是实现了NestedScrollingParent接口的自定义ViewGroup,里面的内容部分是一个RecylceView 。
这篇文章我们只关心RecylceView 是如何与根布局的ViewGroup进行嵌套滚动的。
首先,我们先了解下几个关键的接口或类,如下:
NestedScrollingChild:支持滚动的子View需要实现一套接口。充当事件源头的角色。(recycleView)
NestedScrollingChildHelper:将子View的滑动事件转发到相应的父View,让父View来处理事件。
NestedScrollingParent:包括滚动子View的父View需要实现的接口。
NestedScrollingParentHelper:父View中会使用的辅助类。
原理分析:
首先我们看看RecyclerView的继承体系,如下所示:
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild {}
从上面的代码可以看出,RecyclerView实现了NestedScrollingChild,那它就是事件的源头,也就代表着上面一直说的子View的角色。顺便说一下,此处的RecyclerView也实现了ScrollingView。
RecyclerView的实现代码很多。但根据上面的分析,接下来我们直接去看RecyclerView的onTouchEvent方法了,这里是我们的战场。这个很重要。代码如下:
@Override
public boolean onTouchEvent(MotionEvent e) {
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
switch (action) {
case MotionEvent.ACTION_DOWN: {
mScrollPointerId = e.getPointerId(0);
mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontally) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertically) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
startNestedScroll(nestedScrollAxis);
}
break;
}
return true;
}
在上述代码中获取到RecyclerView支持的滚动方向。水平方向或者垂直方向。在MotionEvent.ACTION_DOWN中,获取到RecyclerView滚动的方向。记录初始位置。然后调用startNestedScroll(nestedScrollAxis);代码如下:
@Override
public boolean startNestedScroll(int axes) {
return getScrollingChildHelper().startNestedScroll(axes);
}
在该方法中会去调用getScrollingChildHelper().startNestedScroll(axes);将事件转发给父View。getScrollingChildHelper()返回的就是NestedScrollingChildHelper(RecyclerView.this)。这里的参数是RecyclerView,在NestedScrollingChildHelper中是直接赋值给mView字段的。代码如下:
private NestedScrollingChildHelper getScrollingChildHelper() {
if (mScrollingChildHelper == null) {
mScrollingChildHelper = new NestedScrollingChildHelper(this);
}
return mScrollingChildHelper;
}
下面我们看看NestedScrollingChildHelper的构造方法,代码如下:public NestedScrollingChildHelper(View view) {
mView = view;
}
看到了吧,这里的mView就是RecyclerView。这对于我们分析接下来的一系列方法的参数很有帮助。NestedScrollingChildHelper的startNestedScroll方法是真正将事件传递到父View的地方。代码如下:
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;
while (p != null) {
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
mNestedScrollingParent = p;
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
在NestedScrollingChildHelper的startNestedScroll方法中会去递归的寻找有特征的父View,此处调用了ViewParentCompat.onStartNestedScroll(ViewParent parent, View child, View target,int nestedScrollAxes)方法。若找到父View,则将父View记录到变量mNestedScrollingParent 中,在接下来的事件中直接使用。
如果有找到父View,并且父View的onStartNestedScroll方法返回true(代码父View接受滑动事件,比如父View只接受垂直滑动事件,就可以根据坐标轴进行方向判断是否是垂直方向,并返回true),还会调用ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes)方法。
在继续分析ViewParentCompat.onStartNestedScroll方法实现之前,有必要解释下它的几个参数都是啥意思。这个从上面的while循环中可以得出:
parent:是实现了NestedScrollingParent或者5.0之后版本的ViewGroup
child:是parent的直接子View
target:就是构造NestedScrollingChildHelper传递进来的RecyclerView,如果parent直接包含了RecyclerView。那么child和target相同。
nestedScrollAxes:是RecyclerView滚动时的方向。
总结一下ACTION_DOWN做了什么事:
获取到滑动方向。
调用辅助类NestedScrollingChildHelper的startNestedScroll方法,
去寻找父View,然后调用父View的onStartNestedScroll和onNestedScrollAccepted方法。
接下来我们分析ACTION_MOVE的实现,代码如下:
@Override
public boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_MOVE: {
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
dx -= mScrollConsumed[0];
dy -= mScrollConsumed[1];
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);
}
}
}
break;
}
return true;
}
从上面的代码可以看出,首先计算出当前滑动的距离dx和dy。然后调用dispatchNestedPreScroll方法。这个方法的前三个参数是最重要的。第三个参数是个数组。也是最最重要的。有两个元素,第1个元素说明父View在x轴上消费的距离。第2个元素说明父View在y轴上消费的距离。这里的消费就是我们所说的父View背着子View滚动的距离。这一点可以从dx -= mScrollConsumed[0];和 dy -= mScrollConsumed[1];看出。此处将父View滚动的距离减掉。然后子View自己滚动剩下的距离。上述代码的scrollByInternal就是子View滚动剩下的距离。
第4个参数是为了矫正子View在屏幕中的位置而使用的,我们不用考虑。
dispatchNestedPreScroll方法的实现最终也是调用NestedScrollingChildHelper的dispatchNestedPreScroll方法。代码如下:
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dx != 0 || dy != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
在该方法中,主要做了两件事:计算mView的位置,并做相应调整,不需要关心。我也不知道为啥?
通过ViewParentCompat.onNestedPreScroll方法,并调用父View的onNestedPreScroll方法。主要就是想知道父View是否消费了某个方向的距离。如果父View有消费某个方向上的距离,整个方法就返回true。只有返回true,ACTION_MOVE中的dx和dy才会进行-=操作。
在ACTION_MOVE中还有一段代码很重要,那就是当子View当前处于拖拽状态时(mScrollState == SCROLL_STATE_DRAGGING)会执行的方法,那就是scrollByInternal方法。该方法的代码如下:
boolean scrollByInternal(int x, int y, MotionEvent ev) {
int unconsumedX = 0, unconsumedY = 0;
int consumedX = 0, consumedY = 0;
consumePendingUpdateOperations();
if (mAdapter != null) {
eatRequestLayout();
onEnterLayoutOrScroll();
TraceCompat.beginSection(TRACE_SCROLL_TAG);
if (x != 0) {
consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
unconsumedX = x - consumedX;
}
if (y != 0) {
consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
unconsumedY = y - consumedY;
}
TraceCompat.endSection();
repositionShadowingViews();
onExitLayoutOrScroll();
resumeRequestLayout(false);
}
if (!mItemDecorations.isEmpty()) {
invalidate();
}
if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)) {
// Update the last touch co-ords, taking any scroll offset into account
mLastTouchX -= mScrollOffset[0];
mLastTouchY -= mScrollOffset[1];
if (ev != null) {
ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
}
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
} else if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
if (ev != null) {
pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
}
considerReleasingGlowsOnScroll(x, y);
}
if (consumedX != 0 || consumedY != 0) {
dispatchOnScrolled(consumedX, consumedY);
}
if (!awakenScrollBars()) {
invalidate();
}
return consumedX != 0 || consumedY != 0;
}
该方法内部,主要做了3件事:让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。
计算出子View当前以及滚动的距离和未滚动的距离。
根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的
dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。
注意:上述方法中的已滚动距离和未滚动距离都是相对于子View的dx或dy的。scrollByInternal的参数x和y就是dx和dy。consumed和unconsumed都是在dx或dy的基础上进行计算的。
下面我们接着分析ACTION_UP事件,代码如下:
@Override
public boolean onTouchEvent(MotionEvent e) {
if (mLayoutFrozen || mIgnoreMotionEventTillDown) {
return false;
}
if (dispatchOnItemTouch(e)) {
cancelTouch();
return true;
}
if (mLayout == null) {
return false;
}
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
boolean eventAddedToVelocityTracker = false;
final MotionEvent vtev = MotionEvent.obtain(e);
final int action = MotionEventCompat.getActionMasked(e);
final int actionIndex = MotionEventCompat.getActionIndex(e);
if (action == MotionEvent.ACTION_DOWN) {
mNestedOffsets[0] = mNestedOffsets[1] = 0;
}
vtev.offsetLocation(mNestedOffsets[0], mNestedOffsets[1]);
switch (action) {
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
eventAddedToVelocityTracker = true;
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally ?
-VelocityTrackerCompat.getXVelocity(mVelocityTracker, mScrollPointerId) : 0;
final float yvel = canScrollVertically ?
-VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId) : 0;
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
resetTouch();
} break;
}
return true;
}
在上述代码中,进行了水平和垂直方向上的滑动速度判断,如果有一个速度不等于0,就代表快速滑动,会调用fling((int) xvel, (int) yvel))方法。代码如下:
public boolean fling(int velocityX, int velocityY) {
final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
final boolean canScrollVertical = mLayout.canScrollVertically();
if (!canScrollHorizontal || Math.abs(velocityX) < mMinFlingVelocity) {
velocityX = 0;
}
if (!canScrollVertical || Math.abs(velocityY) < mMinFlingVelocity) {
velocityY = 0;
}
if (velocityX == 0 && velocityY == 0) {
// If we don't have any velocity, return false
return false;
}
if (!dispatchNestedPreFling(velocityX, velocityY)) {
final boolean canScroll = canScrollHorizontal || canScrollVertical;
dispatchNestedFling(velocityX, velocityY, canScroll);
if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
return true;
}
if (canScroll) {
velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
mViewFlinger.fling(velocityX, velocityY);
return true;
}
}
return false;
}
在该方法,主要做了4件事:根据滑动方向判断速度值的范围,是否小于最小值,如果小于则直接返回。
调用dispatchNestedPreFling(velocityX, velocityY)方法,将速度值转发到父View的onNestedPreFling方法,由父View来决定是否要处理快速滑动事件。如果父View不处理快速滑动事件,则继续调用父View的
dispatchNestedFling(velocityX, velocityY, canScroll)方法。
调用mOnFlingListener.onFling(velocityX, velocityY)方法,用于对齐滚动(左对齐、居中、右对齐)。它的一个实现类是LinearSnapHelper。举个例子就能明白:在水平滚动的RecycleView。如果第一个item向左滚动了2/3,那么我们就选中第二个item,将第一个item完全一次屏幕。
调用mViewFlinger.fling(velocityX, velocityY)方法。并通过调用ScrollerCompat.fling()方法让子View平滑的滚动到相应位置。
在ACTION_UP处理完快速滑动事件后,会调用resetTouch方法,代码如下:
private void resetTouch() {
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
stopNestedScroll();
releaseGlows();
}
在此方法中,最重要的就是调用了stopNestedScroll()方法,该方法的目的就是通知父View滚动停止了。会调用父View的onStopNestedScroll()方法。在该方法中我们可以做些收尾工作。比如让滚动了2/3的view完全滚出屏幕等。下一篇文章,我们将用CoordinatorLayout.behavoir的方式实现文章开头相同的效果,并分析CoordinatorLayout.behavior如何进行事件分发的。