嵌套滚动原理分析-- NestedScrolling

本系列将通过两篇文章:

嵌套滚动原理分析-- 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如何进行事件分发的。
 






 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值