嵌套滚动,顾名思义,就是有至少两个可以滚动的view或者viewgroup,也就说父view和子view都可以滚动,而子view要滚动的时候就要通知父view,我要开始滚动了,由父view决定是否要帮助子view滚动一段距离,父view帮助子view滚动了多少,子view就少移动多少距离。
看了上面这段话,你是不是会想要怎么实现呢?作为Android开发人员应该很容易想到了View事件分发机制。那我们就先自己吓唬吓唬自己吧?猜想一下,用事件分发机制如何实现这种效果。
- 滑动子View,在父View的dispatchTouchEvent方法中进行事件分发。
- 在父View的onInterceptTouchEvent方法中,进行上下滑动方向判断。
- 如果header当前没还有隐藏,但用户在向上滑动,则由父View来处理事件,即onInterceptTouchEvent要返回true。
- 如果header已经隐藏,并且用户在向下滑动,如果子View当前不能再向下滑动了(这句话是说子View中被隐藏的部分是否都已经显示出来了,当前是指列表的item是否都已经全部显示出来了),则由父View来处理事件,即onInterceptTouchEvent返回true。
- 当父View拦截了事件后,接下来就在父View的onTouchEvent方法中进行header的滑动处理。
- 如果父View不拦截事件,则由子View自己处理滑动事件,当然也是onInterceptTouchEvent和onTouchEvent的配合。上面只是粗略方案,看起来是可行的,但我们都知道,事件拦截机制是有弊端的。只要父view拦截了事件,接下来的一系列手势都有父View处理(down、move、up手势)。你需要在onInterceptTouchEvent和onTouchEvent都去处理down、move、up手势。根据MotionEvent取获取x和y坐标分别进行拦截判断和滑动处理。我感觉是非常麻烦的。其实Google的大神们应该是已经知道了我们这种痛苦。所以直接给了我们一个事件分发机制的简化版本(此处说法不是很准确)。那么他们是怎么实现这套机制的呢?
他们的实现是这样的:
当子View触发手势事件的时候,在onTouchEvent中会通知有某种特征的父View(这个特征待会再说),并且父View必须决定是否要处理某个事件,以及怎样处理某个事件。父View处理完了之后,子View再继续处理。
貌似好空洞哈,我感觉也是,下面我们就通过源代码来分析这套机制的实现原理。
在阅读源代码之前,得先要知道几个关键的接口或类,如下:
NestedScrollingChild:支持滚动的子View需要实现一套接口。
NestedScrollingChildHelper:将子View的滑动事件转发到相应的父View,让父View来处理事件。
NestedScrollingParent:包括滚动子View的父View需要实现的接口。这玩意就是我们上面说的父View必须具有的特性,也就是说父View必须要实现这个接口,稍后的源代码中会看到解释的。
NestedScrollingParentHelper:父View中会使用的辅助类。此类只有3个方法,基本没干啥事。
知道了这几个接口或类之后,下面我们就开始分析源代码了,由于在我们的项目中使用的是RecyclerView,当RecyclerView滑动的时候,去显示或隐藏header。那么接下来我们就以RecyclerView为例来看看这套机制的实现原理:
首先我们看看RecyclerView的继承体系,如下所示:
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild {}
从上面的代码可以看出,RecyclerView实现了NestedScrollingChild,那它就是事件的源头,也就代表着上面一直说的子View的角色。顺便说一下,此处的RecyclerView也实现了ScrollingView。这个很重要哈。在我的代码实现中就用到了这玩意来做判断。
RecyclerView的实现代码很多。但根据上面的分析,接下来我们直接去看RecyclerView的onTouchEvent方法了,这里是我们的战场。这个很重要。代码如下:
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);代码如下:
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方法的实现是这样的:
1. api版本低于5.0的就判断parent是否实现了NestedScrollingParent接口。
2. 在5.0及以后的版本中直接调用了ViewParent中的onStartNestedScroll方法。
3. 1和2都是调用onStartNestedScroll方法,告诉父View开始滚动了。
这里有必要说明下:
我们都知道ViewGroup继承自ViewParent。代码如下:
public abstract class ViewGroup extends View implements ViewParent, ViewManager
在5.0之前,支持嵌套滚动的父View都必须实现NestedScrollingParent,但5.0之后,NestedScrollingParent接口中的方法,在ViewParent中全部都有声明。由此可见Google对嵌套滚动的重视。
在继续分析ViewParentCompat.onStartNestedScroll方法实现之前,有必要解释下它的几个参数都是啥意思。这个从上面的while循环中可以得出:
· parent:是实现了NestedScrollingParent或者5.0之后版本的ViewGroup
· child:是parent的直接子View
· target:就是构造NestedScrollingChildHelper传递进来的RecyclerView,如果parent直接包含了RecyclerView。那么child和target相同。
· nestedScrollAxes:是RecyclerView滚动时的方向。
下面来看看ViewParentCompat.onStartNestedScroll的实现,5.0之前的实现如下:
@Override
public boolean onStartNestedScroll(ViewParent parent, View child, View target,
int nestedScrollAxes) {
if (parent instanceof NestedScrollingParent) {
return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
nestedScrollAxes);
}
return false;
}
上述代码就是判断父view是否实现了NestedScrollingParent接口。
在5.0之后的代码实现如下:
//ViewParentCompat.ViewParentCompatLollipopImpl静态类中的代码
@Override
public boolean onStartNestedScroll(ViewParent parent, View child, View target,
int nestedScrollAxes) {
return ViewParentCompatLollipop.onStartNestedScroll(parent, child, target,
nestedScrollAxes);
}
//ViewParentCompatLollipop的代码
public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
int nestedScrollAxes) {
try {
return parent.onStartNestedScroll(child, target, nestedScrollAxes);
} catch (AbstractMethodError e) {
Log.e(TAG, "ViewParent " + parent + " does not implement interface " +
"method onStartNestedScroll", e);
return false;
}
}
上述代码就直接调用了ViewParent的onStartNestedScroll方法的。
说明:由于我们这次的滚动父View实现是直接继承自NestedScrollingParent。所以接下来的分析只针对5.0之前代码,5.0之后的代码不做分析。
总结一下ACTION_DOWN做了什么事:
1. 获取到滑动方向。
2. 调用辅助类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;
}
在该方法中,主要做了两件事:
1. 计算mView的位置,并做相应调整,不需要关心。我也不知道为啥?
2. 通过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件事:
1. 让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。
2. 计算出子View当前以及滚动的距离和未滚动的距离。
3. 根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的
dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。
注意:上述方法中的已滚动距离和未滚动距离都是相对于子View的dx或dy的。scrollByInternal的参数x和y就是dx和dy。consumed和unconsumed都是在dx或dy的基础上进行计算的。
下面我们接着分析ACTION_UP事件,代码如下:
该方法内部,主要做了3件事:
1.让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。
2.计算出子View当前以及滚动的距离和未滚动的距离。
3.根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的
dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。
4.
注意:上述方法中的已滚动距离和未滚动距离都是相对于子View的dx或dy的。scrollByInternal的参数x和y就是dx和dy。consumed和unconsumed都是在dx或dy的基础上进行计算的。
下面我们接着分析ACTION_UP事件,代码如下:
在上述代码中,进行了水平和垂直方向上的滑动速度判断,如果有一个速度不等于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件事:
1. 根据滑动方向判断速度值的范围,是否小于最小值,如果小于则直接返回。
2. 调用dispatchNestedPreFling(velocityX, velocityY)方法,将速度值转发到父View的onNestedPreFling方法,由父View来决定是否要处理快速滑动事件。如果父View不处理快速滑动事件,则继续调用父View的
dispatchNestedFling(velocityX, velocityY, canScroll)方法。
3. 调用mOnFlingListener.onFling(velocityX, velocityY)方法,用于对齐滚动(左对齐、居中、右对齐)。它的一个实现类是LinearSnapHelper。举个例子就能明白:在水平滚动的RecycleView。如果第一个item向左滚动了2/3,那么我们就选中第二个item,将第一个item完全一次屏幕。
4. 调用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完全滚出屏幕等。
好了,到目前位置父View实现的NestedScrollingParent的接口中方法,是在何时被回调我们都知道了,接下来就要开始实战了。