NestedScrolling嵌套滚动原理

本文详细解析了Android中嵌套滚动机制的实现原理,通过分析RecyclerView的触摸事件处理流程,阐述了子View如何通知父View参与滚动过程,以及父View如何响应子View的滚动请求。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


嵌套滚动,顾名思义,就是有至少两个可以滚动的view或者viewgroup,也就说父view和子view都可以滚动,而子view要滚动的时候就要通知父view,我要开始滚动了,由父view决定是否要帮助子view滚动一段距离,父view帮助子view滚动了多少,子view就少移动多少距离。

看了上面这段话,你是不是会想要怎么实现呢?作为Android开发人员应该很容易想到了View事件分发机制。那我们就先自己吓唬吓唬自己吧?猜想一下,用事件分发机制如何实现这种效果。

  1. 滑动子View,在父ViewdispatchTouchEvent方法中进行事件分发。
  2. 在父ViewonInterceptTouchEvent方法中,进行上下滑动方向判断。

  3. 如果header当前没还有隐藏,但用户在向上滑动,则由父View来处理事件,即onInterceptTouchEvent要返回true

  • 如果header已经隐藏,并且用户在向下滑动,如果子View当前不能再向下滑动了(这句话是说子View中被隐藏的部分是否都已经显示出来了,当前是指列表的item是否都已经全部显示出来了),则由父View来处理事件,即onInterceptTouchEvent返回true

  • 当父View拦截了事件后,接下来就在父ViewonTouchEvent方法中进行header的滑动处理。

  • 如果父View不拦截事件,则由子View自己处理滑动事件,当然也是onInterceptTouchEventonTouchEvent的配合。

    上面只是粗略方案,看起来是可行的,但我们都知道,事件拦截机制是有弊端的。只要父view拦截了事件,接下来的一系列手势都有父View处理(downmoveup手势)。你需要在onInterceptTouchEventonTouchEvent都去处理downmoveup手势。根据MotionEvent取获取xy坐标分别进行拦截判断和滑动处理。我感觉是非常麻烦的。其实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的实现代码很多。但根据上面的分析,接下来我们直接去看RecyclerViewonTouchEvent方法了,这里是我们的战场。这个很重要。代码如下:


    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()返回的就是NestedScrollingChildHelperRecyclerView.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。这对于我们分析接下来的一系列方法的参数很有帮助。

    NestedScrollingChildHelperstartNestedScroll方法是真正将事件传递到父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;
    }

    NestedScrollingChildHelperstartNestedScroll方法中会去递归的寻找有特征的父View,此处调用了ViewParentCompat.onStartNestedScroll(ViewParent parent, View child, View target,int nestedScrollAxes)方法。若找到父View,则将父View记录到变量mNestedScrollingParent 中,在接下来的事件中直接使用。如果有找到父View,并且父ViewonStartNestedScroll方法返回true(代码父View接受滑动事件,比如父View只接受垂直滑动事件,就可以根据坐标轴进行方向判断是否是垂直方向,并返回true),还会调用ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes)方法。


    ViewParentCompat.onStartNestedScroll方法的实现是这样的:

    1. api版本低于5.0的就判断parent是否实现了NestedScrollingParent接口。

    2. 5.0及以后的版本中直接调用了ViewParent中的onStartNestedScroll方法。

    3. 12都是调用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。那么childtarget相同。

    · 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;
        }
    }

    上述代码就直接调用了ViewParentonStartNestedScroll方法的。

     

    说明:由于我们这次的滚动父View实现是直接继承自NestedScrollingParent。所以接下来的分析只针对5.0之前代码,5.0之后的代码不做分析。

    总结一下ACTION_DOWN做了什么事:

    1. 获取到滑动方向。

    2. 调用辅助类NestedScrollingChildHelperstartNestedScroll方法,去寻找父View,然后调用父ViewonStartNestedScrollonNestedScrollAccepted方法。

     

    接下来我们分析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;
    }

    从上面的代码可以看出,首先计算出当前滑动的距离dxdy。然后调用dispatchNestedPreScroll方法。这个方法的前三个参数是最重要的。第三个参数是个数组。也是最最重要的。有两个元素,第1个元素说明父Viewx轴上消费的距离。第2个元素说明父Viewy轴上消费的距离。这里的消费就是我们所说的父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方法,并调用父ViewonNestedPreScroll方法。主要就是想知道父View是否消费了某个方向的距离。如果父View有消费某个方向上的距离,整个方法就返回true。只有返回trueACTION_MOVE中的dxdy才会进行-=操作。

    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沿着水平或者垂直方向,将剩下的dxdy滚动完。

    2. 计算出子View当前以及滚动的距离和未滚动的距离。

    3. 根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的

    dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。


    注意:上述方法中的已滚动距离和未滚动距离都是相对于子Viewdxdy的。scrollByInternal的参数xy就是dxdyconsumedunconsumed都是在dxdy的基础上进行计算的。

    下面我们接着分析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)方法,将速度值转发到父ViewonNestedPreFling方法,由父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滚动停止了。会调用父ViewonStopNestedScroll()方法。在该方法中我们可以做些收尾工作。比如让滚动了2/3view完全滚出屏幕等。

    好了,到目前位置父View实现的NestedScrollingParent的接口中方法,是在何时被回调我们都知道了,接下来就要开始实战了。









评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值