NestedScrolling 学习

190 阅读4分钟

综述

NestedScrolling 主要用于嵌套滑动。

主要思路:子 View 收到滑动事件后,先交给父 view 进行消费,父 view 消费后剩余的距离才是子 view 自己消费的距离

在高版本 View 中默认都实现了嵌套滑动相关的方法。下面以 RecyclerView 源码进行分析。为描述方便,将 RecyclerView 称为 child、子 View 等。

startNestedScroll 与 onStartNestedScroll

前者由子 View 调用询问父 View 是否要嵌套滑动,参数为滑动方向(垂直、水平或垂直+水平),后者是父 View 中对应的回调。

RecyclerView#onTouch 在处理 ACTION_DOWN 时会调用 startNestedScroll()。

startNestedScroll() 的实现在 View.java 中,它的主要思路是依次遍历祖先 View,直到 onStartNestedScroll() 返回 true,或者本次不需要嵌套滑动

关于 onStartNestedScroll() 的实现可参考 CoordinatorLayout 实现。

// View.java

public boolean startNestedScroll(int axes) {
    if (hasNestedScrollingParent()) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = getParent();
        View child = this;
        while (p != null) { // while 循环,一直往上遍历
            try {
                // 调用父 View 对应的 onStartNestedScroll
                if (p.onStartNestedScroll(child, this, axes)) {
                    mNestedScrollingParent = p;
                    // 如果父 View 愿意嵌套滑动,给父 View 一直初始化嵌套滑动初始条件的机会
                    p.onNestedScrollAccepted(child, this, axes);
                    return true;
                }
            } catch (AbstractMethodError e) {
               
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

dispatchNestedPreScroll 与 onNestedPreScroll

前者由子 View 调用,在发生滑动时将滑动距离等提交给父 View,由父 View 先享用,它的实现在 View 中。后者是前者的对应的回调,具体实现可参考 CoordinatorLayout。

dispatchNestedPreScroll() 的参数依次为:水平、垂直滑动距离,数组(父 View 将自己消费掉的距离填入),数组(MotionEvent 坐标值的修正值)。

解释下修正值。因为父 View 可能存在滑动,导致子 View 的位置发生变化,因此需要对触摸点的坐标进行修正,保证触摸点与子 View 相对位置不变。

dispatchNestedPreScroll() 的主要逻辑在 RecyclerView#onTouchEvent() 中对于 MOTION_MOVE 的处理部分

// RecyclerView#onTouchEvent() 方法中 MOTION_MOVE 分支节选

int dx = mLastTouchX - x;
int dy = mLastTouchY - y;

// 调用 dispatchNestedPreScroll(),由父 View 先享用
if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {

    // 父 View 享用完了,子 View 需要除掉父 View 享用的部分
    dx -= mScrollConsumed[0];
    dy -= mScrollConsumed[1];
    
    //  vtev 是 MotionEvent 类型。此处就是对 MotionEvent 进行修正
    vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
    
    // Updated the nested offsets
    mNestedOffsets[0] += mScrollOffset[0];
    mNestedOffsets[1] += mScrollOffset[1];
}
if (mScrollState != SCROLL_STATE_DRAGGING) {
    // 省略
}

// 上个 if 中如果的确是滑动(手指移动距离足够大),这个判断就一定成立
if (mScrollState == SCROLL_STATE_DRAGGING) {
    // 记录 lastX, lastY。也会进行修正
    mLastTouchX = x - mScrollOffset[0];
    mLastTouchY = y - mScrollOffset[1];
    
    // 通过 scrollBy 进行移动
    // 【关键点一】
    if (scrollByInternal(
            canScrollHorizontally ? dx : 0,
            canScrollVertically ? dy : 0,
            vtev)) {
        getParent().requestDisallowInterceptTouchEvent(true);
    }
}

上面是 dispatchNestedPreScroll() 的具体使用场景,下面看具体实现


public boolean dispatchNestedPreScroll(int dx, int dy,
        @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
        
    // mNestedScrollingParent 就是在上面 startNestedScroll() 中进行的赋值
    if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
        if (dx != 0 || dy != 0) {
            // 先记录父 View 在滑动前子 View 的位置
            int startX = 0;
            int startY = 0;
            if (offsetInWindow != null) {
                getLocationInWindow(offsetInWindow);
                startX = offsetInWindow[0];
                startY = offsetInWindow[1];
            }
            // 对 consumed 进行补偿,防止出现 null 
            
            // 调用父 View 的 onNestedPreScroll(),用于父 View 执行真正的滑动
            mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
            
            if (offsetInWindow != null) {
                // 父 View 滑动后再次计算子 View 位置
                getLocationInWindow(offsetInWindow);
                // 用 offsetInWindow 记录父 View 滑动导致子 View 的移动量
                // 用于对 MotionEvent 修正。具体逻辑见上面 MOTION_MOVE 处理
                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;
}

dispatchNestedScroll 与 onNestedScroll

经过上面两步后,子 View 有可能未完全消费剩余的滑动距离,因此它需要将未消费的部分再次提交给父 View,由父 View 进行处理。这两个方法就是用来实现该功能的。

在上面的【关键点一】处调用了 scrollByInternal(),它是这两个方法的入口

// RecyclerView.java

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    int unconsumedX = 0, unconsumedY = 0;
    int consumedX = 0, consumedY = 0;

    if (mAdapter != null) {
        // RecyclerView 将滑动处理交给 LayoutManager 处理
        if (x != 0) {
            consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
            unconsumedX = x - consumedX;
        }
        if (y != 0) {
            consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
            unconsumedY = y - consumedY;
        }
    }
    
    // 调用 dispatchNestedScroll() 
    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)) {
        // 与 pre 阶段类似,由于父 View 的移动需要对 MotionEvent 进行修正
        
        // 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];
    } 
    // 如果父 View 也不愿意处理剩余的距离,要么 RecyclerView 显示滑到头后的逻辑,要么就浪费掉
    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);
    }
    return consumedX != 0 || consumedY != 0;
}

dispatchNestedScroll() 与 pre 阶段的逻辑完全类似,它会调用父 View 的 onNestedScroll(),将剩余处理提交给父 View。过程略

总结

整个 NestedScrolling 流程已经学习完毕(除了 fling 阶段)。主要思路为:

  1. DOWN 事件时子 View 先询问父 View 要不要嵌套滑动。如果不要,整个流程结束;如果要,继续执行
  2. MOVE 事件时子 View 会将滑动距离先提交给父 View 处理。代码表现是 pre 阶段
  3. 对于【2】中剩余的距离由子 View 自己处理
  4. 对于【3】中剩余距离会再次提交给父 View,看父 View 是否要处理。如果不需要,则自己处理或直接丢掉。