1. 程式人生 > >Android NestedScrolling解決滑動衝突問題(1) - 相關介面

Android NestedScrolling解決滑動衝突問題(1) - 相關介面

Android NestedScrolling解決滑動衝突問題

當父View及子View都可以滑動,並且滑動方向一致時(例如CoordinatorLayout內嵌RecyclerView或者Webview),滑動衝突的解決就需要依賴於Android為我們提供的NestedScrolling介面。

NestedScrolling 介面分為兩個部分:NestedScrollingParentNestedScrollingChild

為方便描述,以下簡稱NestedScrollingParentNP, NestedScrollingChildNC

NestedScrollingChild

包含的介面:

public interface NestedScrollingChild {
    /**
     * 設定當前View是否啟用nested scroll特性
     * @param enabled 是否啟用
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * 當前View是否啟用了nested scroll特性
     * @return
     */
    boolean isNestedScrollingEnabled();

    /**
     * 在axes軸上發起nested scroll開始操作
     * @param axes 滑動方向
     * @return 是否有NestedScrollingParent接受此次滑動請求
     */
boolean startNestedScroll(@ViewCompat.ScrollAxis int axes); /** * 終止nested scroll */ void stopNestedScroll(); /** * 當前是否有NestedScrollingParent接受了此次滑動請求 * @return 返回值 */ boolean hasNestedScrollingParent(); /** * nested scroll的一步滑動操作中,在自己開始滑動處理之前,分配預處理操作(一般為詢問NestedScrollingParent是否消耗部分滑動距離) * @param dx 當前這一步滑動的x軸總距離 * @param dy 當前這一步滑動的y軸總距離 * @param consumed 預處理操作消耗掉的距離(此為輸出引數,consumed[0]為預處理操作消耗掉的x軸距離,consumed[1]為預處理操作消耗掉的y軸距離) * @param offsetInWindow 可選引數,可以為null。為輸出引數,獲取預處理操作使當前view的位置偏移(offsetInWindow[0]和offsetInWindow[1]分別為x軸和y軸偏移) * @return 預處理操作是否消耗了部分或者全部滑動距離 */
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow); /** * 在當前View處理了滑動之後繼續分配滑動操作 (一般在自己處理滑動之後,給NestedScrollingParent機會處理剩餘的滑動距離) * @param dxConsumed 已經消耗了的x軸滑動距離 * @param dyConsumed 已經消耗了的y軸滑動距離 * @param dxUnconsumed 未消耗的x軸滑動距離 * @param dyUnconsumed 未消耗的y軸滑動距離 * @param offsetInWindow 可選引數,可以為null。為輸出引數,獲取預處理操作使當前view的位置偏移(offsetInWindow[0]和offsetInWindow[1]分別為x軸和y軸偏移) * @return */ boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow); /** * 在當前NestedScrollingChild處理fling事件之前進行預處理(一般詢問NestedScrollingParent是否處理消耗此次fling) * @param velocityX x軸速度 * @param velocityY y軸速度 * @return 預處理是否處理消耗了此次fling */ boolean dispatchNestedPreFling(float velocityX, float velocityY); /** * 分配fling操作 * @param velocityX x軸方向速度 * @param velocityY y軸方向速度 * @param consumed 當前NestedScrollingChild是否處理了此次fling * @return NestedScrollingParent是否處理了此次fling */ boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed); }

NCNP的子孫(並非一定是直接子View),也是聯合滑動的請求方,滑動產生的一系列MotionEvent是在此View中跟蹤處理的,一般此View是在 onTouchEvent 中依據對 MotionEvent 的跟蹤分析來發起滑動請求。例如以下 RecyclerViewonTouchEvent 的簡化版本:

@Override
public boolean onTouchEvent(MotionEvent e) {
    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 = e.getActionMasked();
    final int actionIndex = e.getActionIndex();

    if (action == MotionEvent.ACTION_DOWN) {
        mNestedOffsets[0] = mNestedOffsets[1] = 0;
    }
    vtev.offsetLocation(mNestedOffsets[0], mNestedOffsets[1]);

    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, TYPE_TOUCH);
        } break;

        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;

            // 先詢問 NP 是否需要提前消耗滑動距離(部分或者全部)
            if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                // NP消耗了部分滑動距離
                dx -= mScrollConsumed[0]; // NP 消耗的X軸滑動距離
                dy -= mScrollConsumed[1]; // NP消耗的Y軸滑動距離
                vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
                // Updated the nested offsets
                mNestedOffsets[0] += mScrollOffset[0];
                mNestedOffsets[1] += mScrollOffset[1];
            }

            //分析是否本身需要滑動及本身滑動所消耗的滑動距離
            if (mScrollState != SCROLL_STATE_DRAGGING) {
                boolean startScroll = false;
                if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                    if (dx > 0) {
                        dx -= mTouchSlop;
                    } else {
                        dx += mTouchSlop;
                    }
                    startScroll = true;
                }
                if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                    if (dy > 0) {
                        dy -= mTouchSlop;
                    } else {
                        dy += mTouchSlop;
                    }
                    startScroll = true;
                }
                if (startScroll) {
                    setScrollState(SCROLL_STATE_DRAGGING);
                }
            }

            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);
                }
                if (mGapWorker != null && (dx != 0 || dy != 0)) {
                    mGapWorker.postFromTraversal(this, dx, dy);
                }
            }
        } break;

        case MotionEvent.ACTION_POINTER_UP: {
            onPointerUp(e);
        } break;

        case MotionEvent.ACTION_UP: {
            mVelocityTracker.addMovement(vtev);
            eventAddedToVelocityTracker = true;
            mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
            final float xvel = canScrollHorizontally
                    ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
            final float yvel = canScrollVertically
                    ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
            // 分析是否產生fling事件(手機快速滑動之後擡起,檢視繼續滑動) 
            if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                setScrollState(SCROLL_STATE_IDLE);
            }
            resetTouch();
        } break;

        case MotionEvent.ACTION_CANCEL: {
            cancelTouch();
        } break;
    }

    if (!eventAddedToVelocityTracker) {
        mVelocityTracker.addMovement(vtev);
    }
    vtev.recycle();

    return true;
}

scrollByInternal簡化版:

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

    if (mAdapter != null) {
        if (x != 0) {
            // 自己滑動消耗的X軸滑動距離
            consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
            //尚未消耗的X軸滑動距離
            unconsumedX = x - consumedX;
        }
        if (y != 0) {
            // 自己滑動消耗的Y軸滑動距離
            consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
            //尚未消耗的Y軸滑動距離
            unconsumedY = y - consumedY;
        }
    }

    // 通知 NP 繼續消耗剩餘的滑動距離
    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            TYPE_TOUCH)) {
        // 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];
    } 
    
    // 滑動距離是否已經完全消耗
    return consumedX != 0 || consumedY != 0;
}

所以針對一次滑動操作,NC的介面呼叫順序為:

startNestedScroll -> dispatchNestedPreScroll -> dispatchNestedScroll -> stopNestedScroll

一般性的處理邏輯可以用以下虛擬碼總結:

    private int mLastX;
    private int mLastY;
    private int[] mConsumed = new int[2];
    private int[] mOffsetInWindow = new int[2];
    @Override
    void onTouchEvent(MotionEvent event) {
        int eventX = (int) event.getRawX();
        int eventY = (int) event.getRawY();
        int action = event.getAction();
        int deltaX = eventX - mLastX;
        int deltaY = eventY - mLastY;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally()) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically()) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis);
                break;
            case MotionEvent.ACTION_MOVE:
                if (dispatchNestedPreScroll(deltaX, deltaY, mConsumed, mOffsetInWindow)) {
                    deltaX -= mConsumed[0];
                    deltaY -= mConsumed[1];
                }
                int internalScrolledX = internalScrollByX(deltaX);
                int internalScrolledY = internalScrollByY(deltaY);
                deltaX -= internalScrolledX;
                deltaY -= internalScrolledY;
                if (deltaX != 0 || deltaY != 0) {
                    dispatchNestedScroll(mConsumed[0] + internalScrolledX, mConsumed[1] + internalScrolledY, deltaX, deltaY, mOffsetInWindow);
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                stopNestedScroll();
                break;
        }
        mLastX = eventX;
        mLastY = eventY;
    }

    /**
     * X軸方向滑動
     * @param deltaX 滑動距離
     * @return 消耗的滑動距離
     */
    abstract int internalScrollByX(int deltaX);

    /**
     * Y軸方向滑動
     * @param deltaY 滑動距離
     * @return 消耗的滑動距離
     */
    abstract int internalScrollByY(int deltaY);

    /**
     * 是否支援橫向滑動
     * @return 是否支援
     */
    abstract boolean canScrollHorizontally();

    /**
     * 是否支援豎向滑動
     * @return 是否支援
     */
    abstract boolean canScrollVertically();

NestedScrollingParent

包含介面:


public interface NestedScrollingParent {

    /**
     * 對NP子孫開始滑動請求的迴應(NestedScrollingChild.startNestedScroll)
     * @param child 包含發起請求的NP子孫view的直接子view
     * @param target 發起請求的NP子孫view
     * @param axes 滑動方向
     * @return 是否響應此滑動事件
     */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ViewCompat.ScrollAxis int axes);

    /**
     * 對開始滑動響應的回撥(onStartNestedScroll返回true之後會有此回撥產生),使NestedScrollingParent有做滑動初始化工作的時機
     * @param child 包含發起請求的NP子孫view的直接子view
     * @param target 發起請求的NP子孫view
     * @param axes 滑動方向
     */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ViewCompat.ScrollAxis int axes);

    /**
     * 終止nested scroll的回撥(NestedScrollingChild呼叫stopNestedScroll)
     * @param target 發起請求的NP子孫view
     */
    void onStopNestedScroll(@NonNull View target);

    /**
     * 在NestedScrollingChild處理滑動之前,預處理此滑動
     * @param target 發起請求的NP子孫view
     * @param dx x軸滑動距離
     * @param dy y軸滑動距離
     * @param consumed 回填引數,填入此次預處理消耗掉的滑動距離
     */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /**
     * 處理NestedScrollingChild未消耗完的滑動距離
     * @param target 發起請求的NP子孫view
     * @param dxConsumed 已消耗的x軸滑動距離
     * @param dyConsumed 已消耗的y軸滑動距離
     * @param dxUnconsumed 未消耗的x軸滑動距離
     * @param dyUnconsumed 未消耗的y軸滑動距離
     */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                                 int dxUnconsumed, int dyUnconsumed);

    /**
     * 在NestedScrollingChild之前預處理fling事件
     * @param target 發起請求的NP子孫view
     * @param velocityX x軸fling速度
     * @param velocityY y軸fling速度
     * @return 是否處理此fling
     */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /**
     * 處理fling事件
     * @param target 發起請求的NP子孫view
     * @param velocityX x軸fling速度
     * @param velocityY y軸fling速度
     * @param consumed NestedScrollingChild是否已處理此fling
     * @return 是否處理此fling
     */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);


    /**
     * 獲取滑動方向
     * @return 滑動方向
     */
    @ViewCompat.ScrollAxis
    int getNestedScrollAxes();
}

介面呼叫順序

  1. NC在處理MotionEvent時,決定發起滑動請求,呼叫startNestedScroll
  2. 呼叫startNestedScroll會向上逐層遍歷父view,呼叫其onStartNestedScroll介面,如果返回true,則此view為與此次nested scroll聯動的NP並中斷遍歷;返回false則繼續向上層遍歷直到根view。如果遍歷到根view還沒找到聯動NP,則後續滑動不可用聯動。如果找到了,則進入第3步。
  3. NC呼叫NP的onNestedScrollAccepted介面。
  4. NP的onNestedScrollAccepted介面被呼叫,做一些滑動初始工作。
  5. NC探測到使用者互動產生了滑動距離,呼叫NP的onNestedPreScroll介面。
  6. NP的onNestedPreScroll介面被呼叫,預處理此次滑動,消耗部分滑動距離(或者不消耗)。
  7. NC處理剩餘的滑動距離。
  8. 如果NC沒有處理完剩下的滑動距離,則呼叫dispatchNestedScroll。
  9. NP的onNestedScroll被呼叫,自行決定是否繼續處理剩下的滑動距離。
  10. 互動上的滑動終止,NC呼叫stopNestedScroll。
  11. NP的onStopNestedScroll被呼叫。