View 事件傳遞體系知識梳理(2) 巢狀滑動

澤毛發表於2017-12-21

一、引言

  • 巢狀滑動處理的難點在於:當子控制元件消費了事件,那麼父控制元件就不會再有機會處理事件了。
  • 巢狀滑動的基本原理是在子控制元件接收到滑動一段距離的請求時,先詢問父控制元件是否要滑動,如果滑動了父控制元件就通知子控制元件它消耗了一部分滑動距離,子控制元件就只處理剩下的滑動距離,然後子控制元件滑動完畢後再把剩餘的滑動距離傳給父控制元件
  • 這樣父控制元件和子控制元件就有機會對滑動操作作出響應,尤其父控制元件能夠分別在子控制元件處理滑動距離之前和之後對滑動距離進行響應。

二、相容性問題

  • SDK21之後,巢狀滑動相關的邏輯被寫入了ViewViewGroup類。
  • android.support.v4中提供了介面NestedScrollingChildNestedScrollingParent,他們分別定義了ViewViewParent中新增的方法,還有兩個相關輔助類NestedScrollingChildHelperNestedScrollingParentHelper
  • 如果版本是SDK21之前,那麼就會判斷控制元件是否實現了介面,然後呼叫介面的方法,如果是SDK21之後,那麼就可以直接呼叫對應的方法。

三、預設處理邏輯

雖然ViewViewGroup本身就具有巢狀滑動的相關方法,但是預設情況是不會呼叫,因為ViewViewGroup本身不支援滑動,即本身不支援滑動的控制元件即使有巢狀滑動的相關方法也不能進行巢狀滑動。 因此,要讓控制元件支援巢狀滑動,那麼要滿足:

  • 控制元件類具有巢狀滑動的相關方法,要麼僅支援21之後的版本,要麼實現對應的介面。
  • 控制元件要在合適的位置主動調起巢狀滑動方法。

四、相關方法

4.1 NestedScrollingChild

  • startNestedScroll:起始方法,主要作用是找到接收滑動距離資訊的外控制元件。
  • dispatchNestedPreScroll:在內控制元件處理滑動前把滑動資訊分發給外控制元件。
  • dispatchNestedScroll:在內控制元件處理完滑動後把剩下的距離資訊分發給外控制元件。
  • stopNestedScroll:結束方法,主要作用是清空巢狀滑動的相關狀態。
  • setNestedScrollingEnabledisNestedScrollingEnabled:用來判斷控制元件是否支援巢狀滑動。
  • dispatchNestedPreFlingdispatchNestedFling:和Scroll的對應方法類似,但是分發的是Fling資訊。

4.2 NestedScrollingParent

因為內控制元件是發起者,所以外控制元件的大部分方法都是被內控制元件的對應方法所回撥的。

  • onStartNestedScroll:對應startNestedScroll,內控制元件通過呼叫外控制元件的這個方法來確定外控制元件是否接收滑動資訊。
  • onNestedScrollAccepted:當外控制元件確定接收滑動資訊後該方法被回撥,可以讓外控制元件做一些前期工作。
  • onNestedPreScroll:關鍵方法,接收內控制元件處理滑動前的距離資訊,在這裡外控制元件可以優先響應滑動操作,消耗部分或者全部滑動距離。
  • onNestedScroll:關鍵方法,接收內控制元件處理完滑動後的距離資訊,在這裡外控制元件可以選擇是否處理剩餘的滑動資訊。
  • onStopNestedScroll:對應stopNestedScroll,用來做一些收尾工作。
  • getNestedScrollAxes:返回巢狀滑動的方向。
  • onNestedPreFlingonNestedFling:同上。

五、NestedScrollView

5.1 收到down事件,尋找外控制元件

NestedScrollView實際上是一個FrameLayout,同時它實現了NestedScrollingParent、NestedScrollingChild、ScrollingView這三個介面,它既可以用來作為外控制元件,也可以用來作為內控制元件。

我們先從入口函式startNestedScroll方法看起,它在NestedScrollView中呼叫的地方有以下三處:

  • public boolean onInterceptTouchEvent(MotionEvent ev)
  • public boolean onTouchEvent(MotionEvent ev)
  • public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes)

而在startNestedScroll又會呼叫mChildHelper/ViewstartNestedScroll方法,下面我們來看一下它的實現,它遍歷它所有的祖先節點,並呼叫每個節點的onStartNestedScroll(child, this,axes)方法,如果該方法返回了true,那麼就將他作為巢狀滑動的外控制元件記錄下來,之後所有和外控制元件的互動都是通過mNestedScrollingParent來實現的,接下來呼叫它的onNestedScrollAccepted(child, this, axes)方法,並停止遍歷,返回true。如果它所有的祖先結點都不滿足巢狀滑動的條件,那麼最終返回false

    public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = getParent();
            View child = this;
            while (p != null) {
                try {
                    if (p.onStartNestedScroll(child, this, axes)) {
                        mNestedScrollingParent = p;
                        p.onNestedScrollAccepted(child, this, axes);
                        return true;
                    }
                } catch (AbstractMethodError e) {
                    Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
                            "method onStartNestedScroll", e);
                    // Allow the search upward to continue
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
複製程式碼

接下來,我們看一下mParentHelper/ViewGrouppublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes),它在ViewGroup預設值是返回false

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        return false;
    }
複製程式碼

而在NestedScrollView中的條件是:

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }
複製程式碼

在接著呼叫的onNestedScrollAccepted中,ViewGroup記錄下axes的值:

    @Override
    public void onNestedScrollAccepted(View child, View target, int axes) {
        mNestedScrollAxes = axes;
    }
複製程式碼

NestedScrollView則會繼續呼叫startNestedScroll來尋找它的外控制元件:

    @Override
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
    }
複製程式碼

總結:第一個階段主要是為了尋找到巢狀滑動的外控制元件,並確定滑動的方向。

5.2 收到move事件,交給外控制元件處理一部分的滑動距離

之後的滑動就需要通過public boolean onTouchEvent(MotionEvent ev)中的ACTION_MOVE來處理了,我們來看一下NestedScrollView的處理邏輯:

            case MotionEvent.ACTION_MOVE:
                final int activePointerIndex = MotionEventCompat.findPointerIndex(ev,
                        mActivePointerId);
                if (activePointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                    break;
                }
                //1.獲得當前的y座標
                final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
                //2.記錄該次滑動的距離
                int deltaY = mLastMotionY - y;
                //3.如果有外控制元件,那麼交給它先處理滑動事件,這裡傳入了3個引數:
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
                    deltaY -= mScrollConsumed[1];
                    vtev.offsetLocation(0, mScrollOffset[1]);
                    mNestedYOffset += mScrollOffset[1];
                }
                if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                    mIsBeingDragged = true;
                    if (deltaY > 0) {
                        deltaY -= mTouchSlop;
                    } else {
                        deltaY += mTouchSlop;
                    }
                }
                //.....
複製程式碼

ViewdispatchNestedPreScroll,它通過先前儲存下來的外控制元件變數,把當前滑動的距離傳給它來處理,在ViewGroup中這個函式什麼事情也沒有做,如果我們要實現自己的巢狀滑動邏輯,那麼就要在這裡面進行處理:

    public boolean dispatchNestedPreScroll(int dx, int dy,
            @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    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;
                //呼叫父控制元件的介面,詢問它是否要消耗滑動事件.
                mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);

                if (offsetInWindow != null) {
                    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;
    }
複製程式碼

這個階段的過程,可以理解為:

  • 得到當前y座標的值
  • 根據上次y座標的值計算出這次滑動的距離deltaY
  • 把這個deltaY值交給外控制元件處理
  • 外控制元件返回兩個陣列,mScrollConsumed表示該階段外控制元件消耗的距離,mScrollOffset表示本次交給外控制元件之後,內控制元件視窗變動的座標值,如果消耗的xy值不為0,那麼該函式返回true
  • deltaY - mScrollConsumed[1]得到內控制元件接下來要處理的距離。

5.3 外控制元件處理完滑動距離後,交給內控制元件滾動

                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    mLastMotionY = y - mScrollOffset[1];

                    final int oldY = getScrollY();
                    final int range = getScrollRange();
                    final int overscrollMode = ViewCompat.getOverScrollMode(this);
                    boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
                            (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS &&
                                    range > 0);

                    // Calling overScrollByCompat will call onOverScrolled, which
                    // calls onScrollChanged if applicable.
                    if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
                            0, true) && !hasNestedScrollingParent()) {
                        // Break our velocity if we hit a scroll barrier.
                        mVelocityTracker.clear();
                    }
                    //.....
                 }
複製程式碼

5.4 內控制元件滾動完畢後,交給外控制元件繼續處理

                    final int scrolledDeltaY = getScrollY() - oldY;
                    final int unconsumedY = deltaY - scrolledDeltaY;
                    if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
                        mLastMotionY -= mScrollOffset[1];
                        vtev.offsetLocation(0, mScrollOffset[1]);
                        mNestedYOffset += mScrollOffset[1];
                    } else if (canOverscroll) {
                        //..
                    }
複製程式碼

這裡呼叫了mChildHelper/ViewdispatchNestedScroll方法,它裡面會通過mNestedScrollingParent來通知外控制元件來處理剩餘的距離,在ViewGrouponNestedScroll方法中,什麼也沒有做:

    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable @Size(2) int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
                        dxUnconsumed, dyUnconsumed);

                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
複製程式碼

5.5 收到up事件,停止巢狀滑動

通過呼叫stopNestedScroll方法來停止滑動:

  • public boolean onInterceptTouchEvent(MotionEvent ev)ACTION_UP
  • public boolean onTouchEvent(MotionEvent ev)ACTION_UPACTION_CANCEL

ViewstopNestedScroll方法中,呼叫外控制元件的onStopNestedScroll方法來通知它整個滑動結束:

    public void stopNestedScroll() {
        if (mNestedScrollingParent != null) {
            mNestedScrollingParent.onStopNestedScroll(this);
            mNestedScrollingParent = null;
        }
    }
複製程式碼

六、運用NestedScrollView

下面,我們再通過一個簡單的例子,來看一下使用NestedScrollView的效果,佈局檔案:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <!-- 標題部分 -->
    <android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_height="wrap_content"
        android:layout_width="match_parent">
        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            app:layout_scrollFlags="scroll|enterAlways"
            android:background="@android:color/holo_blue_dark"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize">
        </android.support.v7.widget.Toolbar>
    </android.support.design.widget.AppBarLayout>

    <!-- 內容部分 -->
    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <TextView
                android:text="1"
                android:layout_width="match_parent"
                android:layout_height="200dp"/>
            <TextView
                android:text="2"
                android:layout_width="match_parent"
                android:layout_height="200dp"/>
            <TextView
                android:text="3"
                android:layout_width="match_parent"
                android:layout_height="200dp"/>
            <TextView
                android:text="4"
                android:layout_width="match_parent"
                android:layout_height="200dp"/>
        </LinearLayout>
    </android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
複製程式碼

我們通過CoordinatorLayout把標題部分和內容部分包裹起來,這樣再滑動下面的NestedScrollView時,可以實現標題欄的隱藏和顯示。

相關文章