自定義View事件之進階篇(一)-NestedScrolling(巢狀滑動)機制

AndyJennifer發表於2019-07-29

最近一段時間,一直都在忙於找工作。雖然花費了三個月的時間,但是結果並不是很美滿。想去大廠、想去好公司、想遇見更厲害的人的願望還是沒有實現。或許是自己不夠強大,或許自己不夠努力,或許需要一定運氣。生活總是需要經歷一些波折。沒有誰總是能一帆風順。接下來一段時間內,會繼續更新文章。希望大家能繼續關注。Thanks~

前言

在Lollipop(Android 5.0)時,谷歌推出了NestedScrolling機制,也就是巢狀滑動。本文將帶領大家一起去了解谷歌對該機制的設計。通過閱讀該文,你能瞭解如下知識點:

  • 傳統事件分發機制中巢狀滑動的實現與侷限性。
  • 谷歌NestedScrolling機制的原理實現。
  • NestedScrollingChild與NestedScrollingParent介面的呼叫關係。
  • NestedScrollingChild2與NestedScrollingParent2介面出現的意義。

該部落格中涉及到的示例,在NestedScrollingDemo專案中都有實現,大家可以按需自取。

傳統事件機制處理巢狀滑動的侷限性

在傳統的事件分發機制中,當一個事件產生後,它的傳遞過程遵循如下順序:父控制元件->子控制元件,事件總是先傳遞給父控制元件,當父控制元件不對事件攔截的時候,那麼當前事件又會傳遞給它的子控制元件。一旦父控制元件需要攔截事件,那麼子控制元件是沒有機會接受該事件的。

因此當在傳統事件分發機制中,如果有巢狀滑動場景,我們需要手動解決事件衝突。具體巢狀滑動例子如下圖所示:

例子分析

上述效果實現,請參看NestedTraditionLayout.java

想要實現上圖效果,在傳統滑動機制中,我們需要以下幾個步驟:

  • 我們需要呼叫父控制元件中onInterceptTouchEvent方法來攔截向上滑動。
  • 當父控制元件攔截事件後,需要控制自身的onTouchEvent處理滑動事件,使其滑動至HeaderView隱藏。
  • 當HeaderView滑動至隱藏後,父控制元件就不攔截事件了,而是交給內部的子控制元件(RecyclerView或ListView)處理滑動事件。

使用傳統的事件攔截機制來處理巢狀滑動,我們會發現一個問題,就是整個巢狀滑動是不連貫的。也就是當父控制元件滑動至HeaderView隱藏的時候,這個時候如果想要內部的(RecyclerView或ListView)處理滑動事件。只有抬起手指,重新向上滑動。

熟悉事件分發機制的朋友應該知道,之所以產生不連貫的原因,是因為父控制元件攔截了事件,所以同一事件序列的事件,仍然會傳遞給父控制元件,也就會呼叫其onTouchEvent方法。而不是呼叫子控制元件的onTouchEvent方法。

NestedScrolling機制簡介

為了實現連貫的巢狀滑動,谷歌在Lollipop(Android 5.0)時,推出了NestedScrolling機制。該機制並沒有脫離傳統的事件分發機制,而是在原有的事件分發機制之上,為系統的自帶的ViewGroup和View都增加了手勢滑動與處理fling的方法。同時為了相容低版本(5.0以下,View與ViewGroup是沒有對應的API),谷歌也在support v4包中也提供瞭如下類與介面進行支撐:

父控制元件需要實現的介面與使用到的類:

  • NestedScrollingParent(介面)
  • NestedScrollingParent2(也是介面並繼承NestedScrollingParent)
  • NestedScrollingParentHelper(類)

子控制元件需要實現的介面與使用到的類:

  • NestedScrollingChild(介面)
  • NestedScrollingChild2(也是介面並繼承NestedScrollingChild)
  • NestedScrollingChildHelper(類)

需要注意的是,如果你的Android平臺在5.0以上,那麼你可以直接使用系統ViewGoup與View自帶的方法。但是為了向下相容,建議還是使用support v4包提供的相應介面來實現巢狀滑動。下文也會著重講解這些介面的的使用方式與方法說明。

NestedScrollingParent與NestedScrollingChild介面介紹

在瞭解巢狀滑動具體的使用方式之前,我們需要了解父控制元件與子控制元件對應介面中方法的說明。這裡大家可以先忽略掉NestedScrollingParent2與NestedScrollingChild2介面,因為這兩個介面是為了解決之前對巢狀滑動處理fling效果的Bug。所以對於目前階段的我們只需要瞭解基礎的巢狀滑動規則就夠了。關於NestedScrollingParent2與NestedScrollingChild2介面相關的知識點,會在下文具體描述。那現在我們就先看看基礎的介面的方法介紹吧。

NestedScrollingParent

如果採用介面的方式實現巢狀滑動,我們需要父控制元件要實現NestedScrollingParent介面。介面具體方法如下:

   /**
     * 有巢狀滑動到來了,判斷父控制元件是否接受巢狀滑動
     *
     * @param child            巢狀滑動對應的父類的子類(因為巢狀滑動對於的父控制元件不一定是一級就能找到的,可能挑了兩級父控制元件的父控制元件,child的輩分>=target)
     * @param target           具體巢狀滑動的那個子類
     * @param nestedScrollAxes 支援巢狀滾動軸。水平方向,垂直方向,或者不指定
     * @return 父控制元件是否接受巢狀滑動, 只有接受了才會執行剩下的巢狀滑動方法
     */
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {}

    /**
     * 當onStartNestedScroll返回為true時,也就是父控制元件接受巢狀滑動時,該方法才會呼叫
     */
    public void onNestedScrollAccepted(View child, View target, int axes) {}

    /**
     * 在巢狀滑動的子控制元件未滑動之前,判斷父控制元件是否優先與子控制元件處理(也就是父控制元件可以先消耗,然後給子控制元件消耗)
     *
     * @param target   具體巢狀滑動的那個子類
     * @param dx       水平方向巢狀滑動的子控制元件想要變化的距離 dx<0 向右滑動 dx>0 向左滑動
     * @param dy       垂直方向巢狀滑動的子控制元件想要變化的距離 dy<0 向下滑動 dy>0 向上滑動
     * @param consumed 這個引數要我們在實現這個函式的時候指定,回頭告訴子控制元件當前父控制元件消耗的距離
     *                 consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子控制元件做出相應的調整
     */
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {}

    /**
     * 巢狀滑動的子控制元件在滑動之後,判斷父控制元件是否繼續處理(也就是父消耗一定距離後,子再消耗,最後判斷父消耗不)
     *
     * @param target       具體巢狀滑動的那個子類
     * @param dxConsumed   水平方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dyConsumed   垂直方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dxUnconsumed 水平方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     * @param dyUnconsumed 垂直方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     */
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {}

    /**
     * 巢狀滑動結束
     */
    public void onStopNestedScroll(View child) {}

    /**
     * 當子控制元件產生fling滑動時,判斷父控制元件是否處攔截fling,如果父控制元件處理了fling,那子控制元件就沒有辦法處理fling了。
     *
     * @param target    具體巢狀滑動的那個子類
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @return 父控制元件是否攔截該fling
     */
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {}


    /**
     * 當父控制元件不攔截該fling,那麼子控制元件會將fling傳入父控制元件
     *
     * @param target    具體巢狀滑動的那個子類
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @param consumed  子控制元件是否可以消耗該fling,也可以說是子控制元件是否消耗掉了該fling
     * @return 父控制元件是否消耗了該fling
     */
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {}

    /**
     * 返回當前父控制元件巢狀滑動的方向,分為水平方向與,垂直方法,或者不變
     */
    public int getNestedScrollAxes() {}
複製程式碼

NestedScrollingChild介面介紹

如果採用介面的方式實現巢狀滑動,子控制元件需要實現NestedScrollingChild介面。介面具體方法如下:

   /**
     * 開啟一個巢狀滑動
     *
     * @param axes 支援的巢狀滑動方法,分為水平方向,豎直方向,或不指定
     * @return 如果返回true, 表示當前子控制元件已經找了一起巢狀滑動的view
     */
    public boolean startNestedScroll(int axes) {}

    /**
     * 在子控制元件滑動前,將事件分發給父控制元件,由父控制元件判斷消耗多少
     *
     * @param dx             水平方向巢狀滑動的子控制元件想要變化的距離 dx<0 向右滑動 dx>0 向左滑動
     * @param dy             垂直方向巢狀滑動的子控制元件想要變化的距離 dy<0 向下滑動 dy>0 向上滑動
     * @param consumed       子控制元件傳給父控制元件陣列,用於儲存父控制元件水平與豎直方向上消耗的距離,consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離
     * @param offsetInWindow 子控制元件在當前window的偏移量
     * @return 如果返回true, 表示父控制元件已經消耗了
     */
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {}


    /**
     * 當父控制元件消耗事件後,子控制元件處理後,又繼續將事件分發給父控制元件,由父控制元件判斷是否消耗剩下的距離。
     *
     * @param dxConsumed     水平方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dyConsumed     垂直方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dxUnconsumed   水平方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     * @param dyUnconsumed   垂直方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     * @param offsetInWindow 子控制元件在當前window的偏移量
     * @return 如果返回true, 表示父控制元件又繼續消耗了
     */
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {}

    /**
     * 子控制元件停止巢狀滑動
     */
    public void stopNestedScroll() {}


    /**
     * 當子控制元件產生fling滑動時,判斷父控制元件是否處攔截fling,如果父控制元件處理了fling,那子控制元件就沒有辦法處理fling了。
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @return 如果返回true, 表示父控制元件攔截了fling
     */
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {}

    /**
     * 當父控制元件不攔截子控制元件的fling,那麼子控制元件會呼叫該方法將fling,傳給父控制元件進行處理
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @param consumed  子控制元件是否可以消耗該fling,也可以說是子控制元件是否消耗掉了該fling
     * @return 父控制元件是否消耗了該fling
     */
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {}

    /**
     * 設定當前子控制元件是否支援巢狀滑動,如果不支援,那麼父控制元件是不能夠響應巢狀滑動的
     *
     * @param enabled true 支援
     */
    public void setNestedScrollingEnabled(boolean enabled) {}

    /**
     * 當前子控制元件是否支援巢狀滑動
     */
    public boolean isNestedScrollingEnabled() {}

    /**
     * 判斷當前子控制元件是否擁有巢狀滑動的父控制元件
     */
    public boolean hasNestedScrollingParent() {}
複製程式碼

谷歌巢狀滑動的方法呼叫設計

通過上文,我相信大家大概基本瞭解了NestedScrollingParent與NestedScrollingChild兩個介面方法的作用,但是我們並不知道這些方法之間對應的關係與呼叫的時機。那麼現在我們一起來分析谷歌對整個巢狀滑動過程的實現與設計。為了處理巢狀滑動,谷歌將整個過程分為了以下幾個步驟:

  • 1.當父控制元件不攔截事件,子控制元件收到滑動事件後,會先詢問父控制元件是否支援巢狀滑動。
  • 2.如果父控制元件支援巢狀滑動,那麼父控制元件進行預先滑動。然後將處理剩餘的距離交由給子控制元件處理。
  • 3.子控制元件收到父控制元件剩餘的滑動距離並滑動結束後,如果滑動距離還有剩餘,又會再問一下父控制元件是否需要再繼續消耗剩下的距離。
  • 4.如果子控制元件產生了fling,會先詢問父控制元件是否預先攔截fling。如果父控制元件預先攔截。則交由給父控制元件處理。子控制元件則不處理fling
  • 5.如果父控制元件不預先攔截fling, 那麼會將fling傳給父控制元件處理。同時子控制元件也會處理fling。
  • 6.當整個巢狀滑動結束時,子控制元件通知父控制元件巢狀滑動結束。

對fling效果不熟悉的小夥伴可以檢視該篇文章---RecyclerView之Scroll和Fling

再結合之前我們對NestedScrollingParent與NestedScrollingChild中的方法。我們可以得到相應方法之間的呼叫關係。具體如下圖所示:

方法對應關係

子控制元件方法呼叫時機

當我們瞭解了介面的呼叫關係後,我們需要知道子控制元件對相應巢狀滑動方法的呼叫時機。因為在低版本下,子控制元件向父控制元件傳遞事件需要配合NestedScrollingChildHelper類與NestedScrollingChild介面一起使用。由於篇幅的限制。這裡就不向大家介紹如何構造一個支援巢狀滑動的子控制元件了。在接下來的知識點中都會在NestedScrollingChildView 的基礎上進行講解。希望大家可以結合程式碼與部落格一起理解。

在接下來的章節中,會先講解谷歌在NestedScrollingParent與NestedScrollingChild介面下巢狀滑動的API設計。關於NestedScrollingParent2與NestedScrollingChild2介面會單獨進行解釋。

子控制元件startNestedScroll方法呼叫時機

根據巢狀滑動的機制設定,子控制元件如果想要將事件傳遞給父控制元件,那麼父控制元件是不能攔截事件的。當子控制元件想要將事件交給父控制元件進行預處理,那麼必然會在其onTouchEvent方法,將事件傳遞給父控制元件。需要注意的是當子控制元件呼叫startNestedScroll方法時,只是判斷是否有支援巢狀滑動的父控制元件,並通知父控制元件巢狀滑動開始。這個時候並沒有真正的傳遞相應的事件。故該方法只能在子控制元件的onTouchEvent方法中事件為MotionEvent.ACTION_DOWN時呼叫。虛擬碼如下所示:

public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                mLastX = x;
                mLastY = y;
                //查詢巢狀滑動的父控制元件,並通知父控制元件巢狀滑動開始。這裡預設是設定的豎直方向
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
                break;
            }
        }
        return super.onTouchEvent(event);
    }
複製程式碼

那子view僅僅通過startNestedScroll方法是如何找到父控制元件並通知父控制元件巢狀滑動開始的呢?我們來看看startNestedScroll方法的具體實現,startNestedScroll方法內部會呼叫NestedScrollingChildHelper的startNestedScroll方法。具體程式碼如下所示:

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {//判斷子控制元件是否支援巢狀滑動
            //獲取當前的view的父控制元件
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                 //判斷當前父控制元件是否支援巢狀滑動
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                //繼續向上尋找
                p = p.getParent();
            }
        }
        return false;
    }
複製程式碼

從程式碼中我們可以看出,當子控制元件支援巢狀滑動時,子控制元件會獲取當前父控制元件,並呼叫ViewParentCompat.onStartNestedScroll方法。我們繼續檢視該方法:

public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {//判斷父控制元件是否實現NestedScrollingParent2
            // First try the NestedScrollingParent2 API
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {//如果父控制元件實現NestedScrollingParent
            // Else if the type is the default (touch), try the NestedScrollingParent API
            return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes);
        }
        return false;
    }
複製程式碼

觀察程式碼,我們可以發現,當父控制元件實現NestedScrollingParent介面後,會走IMPL.onStartNestedScroll方法,我們繼續跟下去:

public boolean onStartNestedScroll(ViewParent parent, View child, View target,
                int nestedScrollAxes) {
            if (parent instanceof NestedScrollingParent) {
                return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                        nestedScrollAxes);
            }
            return false;
        }
複製程式碼

最後會呼叫ViewParetCompat中的onStartNestedScroll方法,該方法最終會呼叫父控制元件的onStartNestedScroll方法。繞了一大圈,也就呼叫了父控制元件的onStartNestedScroll來判斷是否支援巢狀滑動。

那現在我們再回到子控制元件的startNestedScroll方法中。我們可以得知,如果當前父控制元件不支援巢狀滑動,那麼會一直向上尋找,直到找到為止。如果仍然沒有找到,那麼接下來的子父控制元件的巢狀滑動方法都不會呼叫。如果子控制元件找到了支援巢狀滑動的父控制元件,那麼接下來會呼叫父控制元件的onNestedScrollAccepted方法,表示父控制元件接受巢狀滑動。

子控制元件dispatchNestedPreScroll方法呼叫時機

當父控制元件接受巢狀滑動後,那麼子控制元件需要將手勢滑動傳遞給父控制元件,因為這裡已經產生了滑動,故會在onTouchEvent中篩選MotionEvent.ACTION_MOVE中的事件,然後呼叫dispatchNestedPreScroll方法這些將滑動事件傳遞給父控制元件。虛擬碼如下所示:

private final int[] mScrollConsumed = new int[2];//記錄父控制元件消耗的距離

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                int dy = mLastY - y;
                int dx = mLastX - x;
                //將事件傳遞給父控制元件,並記錄父控制元件消耗的距離。
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                }
            }
        }

        return super.onTouchEvent(event);
    }
複製程式碼

在上述程式碼中,dy與dx分別為子控制元件豎直與水平方向上的距離,int[] mScrollConsumed豎直用於記錄父控制元件消耗的距離。那麼當我們呼叫dispatchNestedPreScroll的方法,將事件傳遞給父控制元件進行消耗時,那麼子控制元件實際能處理的距離為:

  • 水平方向: dx -= mScrollConsumed[0];
  • 豎直方向: dy -= mScrollConsumed[1];

接下來,我們繼續檢視dispatchNestedPreScroll的方法。

在dispatchNestedPreScroll方法內部會呼叫NestedScrollingChildHelper的dispatchNestedPreScroll方法具體程式碼如下:

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            //獲取當前巢狀滑動的父控制元件,如果為null,直接返回
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            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;
                //呼叫父控制元件的onNestedPreScroll處理事件
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

                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;
    }
複製程式碼

在該方法中,會先判斷獲取當前巢狀滑動的父控制元件。如果父控制元件不為null且支援巢狀滑動,那麼接下來會呼叫ViewParentCompat.onNestedPreScroll()方法。程式碼如下所示:

 public void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
                int[] consumed) {
            if (parent instanceof NestedScrollingParent) {
                ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
            }
        }

複製程式碼

觀察程式碼最終會呼叫父控制元件的onNestedPreScroll方法。需要注意的是,父控制元件可能會將子控制元件傳遞的滑動事件全部消耗。那麼子控制元件就沒有繼續可處理的事件了。

onNestedPreScroll方法在巢狀滑動時判斷父控制元件的滑動距離時尤為重要。

子控制元件dispatchNestedScroll方法呼叫時機

當父控制元件預先處理滑動事件後,也就是呼叫onNestedPreScroll方法並把消耗的距離傳遞給子控制元件後,子控制元件會獲取剩下的事件並消耗。如果子控制元件仍然沒有消耗完,那麼會呼叫dispatchNestedScroll將剩下的事件傳遞給父控制元件。如果父控制元件不處理。那麼又會傳遞給子控制元件進行處理。虛擬碼如下所示:

private final int[] mScrollConsumed = new int[2];//記錄父控制元件消耗的距離

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                int dy = mLastY - y;
                int dx = mLastX - x;
                //將事件傳遞給父控制元件,並記錄父控制元件消耗的距離。
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                    scrollNested(dx,dy);//處理巢狀滑動
                }
            }
        }

        return super.onTouchEvent(event);
    }
    //處理巢狀滑動
    private void scrollNested(int x, int y) {
        int unConsumedX = 0, unConsumedY = 0;
        int consumedX = 0, consumedY = 0;

        //子控制元件消耗多少事件,由自己決定
        if (x != 0) {
            consumedX = childConsumeX(x);
            unConsumedX = x - consumedX;
        }
        if (y != 0) {
            consumedY = childConsumeY(y);
            unConsumedY = y - consumedY;
        }

        //子控制元件處理事件
        childScroll(consumedX, consumedY);

        //子控制元件處理後,又將剩下的事件傳遞給父控制元件
        if (dispatchNestedScroll(consumedX, consumedY, unConsumedX, unConsumedY, mScrollOffset)) {
            //傳給父控制元件處理後,剩下的邏輯自己實現
        }
        //傳遞給父控制元件,父控制元件不處理,那麼子控制元件就繼續處理。
        childScroll(unConsumedX, unConsumedY);

    }
    /**
     * 子控制元件滑動邏輯
     */
    private void childScroll(int x, int y) {
        //子控制元件怎麼滑動,自己實現
    }
    /**
     * 子控制元件水平方向消耗多少距離
     */
    private int childConsumeX(int x) {
        //具體邏輯由自己實現
        return 0;
    }
    /**
     * 子控制元件豎直方向消耗距離
     */
    private int childConsumeY(int y) {
        //具體邏輯由自己實現
        return 0;
    }
複製程式碼

在上述程式碼中,因為子控制元件消耗多少距離,是由子控制元件進行決定的,所以將這些方法抽象了出來了。在子控制元件的dispatchNestedScroll方法內部會呼叫NestedScrollingChildHelper的dispatchNestedScroll方法,具體程式碼如下所示:

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //呼叫父控制元件的onNestedScroll方法。
                ViewParentCompat.onNestedScroll(parent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed, type);

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

該方法內部會呼叫ViewParentCompat.onNestedScroll方法。繼續跟蹤最終會呼叫ViewParentCompat中非靜態的的onNestedScroll方法,程式碼如下所示:

public void onNestedScroll(ViewParent parent, View target, int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed) {
            if (parent instanceof NestedScrollingParent) {
                ((NestedScrollingParent) parent).onNestedScroll(target, dxConsumed, dyConsumed,
                        dxUnconsumed, dyUnconsumed);
            }
        }
複製程式碼

該方法中,最終會呼叫父控制元件的onNestedScroll方法來處理子控制元件剩餘的距離。

子控制元件stopNestedScroll方法呼叫時機

當整個事件序列結束的時候(當手指抬起或取消滑動的時候),需要通知父控制元件巢狀滑動已經結束。故我們需要在OnTouchEvent中篩選MotionEvent.ACTION_UP、MotionEvent.ACTION_CANCEL中的事件,並通過stopNestedScroll()方法通知父控制元件。虛擬碼如下所示:

public boolean onTouchEvent(MotionEvent event) {

        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_UP: {   //當手指抬起的時,結束事件傳遞
                stopNestedScroll();
                break;
            }
            case MotionEvent.ACTION_CANCEL: {   //當手指抬起的時,結束事件傳遞
                stopNestedScroll();
                break;
            }
        }
        return super.onTouchEvent(event);
    }
複製程式碼

在stopNestedScroll()方法中,最終會呼叫父控制元件的onStopNestedScroll()方法,這裡就不做更多的分析了。

子控制元件fling分發時機

現在就剩下最後一個巢狀滑動的方法了!!!對!就是fling。在瞭解子控制元件對fling的處理過程之前,我們先要知道fling代表什麼樣的效果。在Android系統下,手指在螢幕上滑動然後鬆手,控制元件中的內容會順著慣性繼續往手指滑動的方向繼續滾動直到停止,這個過程叫做fling。也就是我們需要在onTouchEvent方法中篩選MotionEvent.ACTION_UP的事件並獲取需要的滑動速度。虛擬碼如下:

fling的中文意思為拋、扔、擲。

public boolean onTouchEvent(MotionEvent event) {
         //新增速度檢測器,用於處理fling
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_UP: {
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                int xvel = (int) mVelocityTracker.getXVelocity();
                int yvel = (int) mVelocityTracker.getYVelocity();  
             if (!dispatchNestedPreFling(velocityX, velocityY)) {
                boolean consumed = canScroll();
                //將fling效果傳遞給父控制元件
                dispatchNestedFling(velocityX, velocityY, consumed);
                 //然後子控制元件再處理fling
                childFling();//子控制元件自己實現怎麼處理fling
                stopNestedScroll();//子控制元件通知父控制元件滾動結束
              }
              stopNestedScroll();//通知父控制元件結束滑動
                break;
            }
        }
        return super.onTouchEvent(event);
    }
複製程式碼

這裡就不在對fling效果是怎麼分發到父控制元件進行解釋啦~~。一定要結合NestedScrollingChildView類進行理解。那麼假設大家都看了原始碼,那麼我們可以得到如下幾點:

  • 子控制元件dispatchNestedPreFling最終會呼叫父控制元件的onNestedPreFling方法。
  • 子控制元件的dispatchNestedFling最終會呼叫onNestedFling方法。
  • 如果父控制元件的攔截fling(也就是onNestedPreFling方法返回為true)。那麼子控制元件是沒有機會處理fling的。
  • 如果父控制元件攔截fling(也就是onNestedPreFling方法返回為false),則父控制元件會呼叫onNestedFling方法與子控制元件同時處理fling。
  • 當父控制元件與子控制元件同時處理fling時,子控制元件會立即呼叫stopNestedScroll方法通知父控制元件巢狀滑動結束。

NestedScrollingChild2與NestedScrollingParent2簡介

最後一個知識點了,大家加油啊!!!!!!

在本文章前半部,我們都是圍繞NestedScrollingChild與NestedScrollingParent進行講解。並沒有提及NestedScrollingChild2與NestedScrollingParent2介面。那這兩個介面是處理什麼的呢?這又要回到上文我們提到的NestedScrollingChild處理fling時的流程了,在谷歌之前的NestedScrollingParent與NestedScrollingChild的API設計中。並沒有考慮如下問題:

  • 父控制元件根本不可能知道子控制元件是否fling結束。子控制元件只是在ACTION_UP中呼叫了stopNestedScroll方法。雖然通知了父控制元件結束巢狀滑動,但是子控制元件仍然可能處於fling中。
  • 子控制元件沒有辦法將部分fling傳遞給父控制元件。父控制元件必須處理整個fling。

而使用NestedScrollingChild2與NestedScrollingParent2這兩個介面,子控制元件就能將fling傳遞給父控制元件,並且父控制元件處理了部分fling後,又可以將剩餘的fling再傳遞給子控制元件。當子控制元件停止fling時,通知父控制元件fling結束了。這和我們之前分析的巢狀滑動是不是很像呢?直接講知識點,大家不是很好理解,看下面這個例子:

NestedScrollingParent效果展示

上述效果實現,請參看NestedScrollingParentLayout.java

在上面例子中是實現了NestedScrollingChild(NestedScrollView或RecyclerView等)與NestedScrollingParent介面的巢狀滑動,我們可以明顯的看出,當我們手指快速向下滑動並抬起的時,子控制元件將fling分發給父控制元件,因為處理的距離不同,這個時候父控制元件已經處理滑動並fling結束,而內部的子控制元件(RecyclerView或NestedScrollView還在滾動,這種給我們的感覺就非常不連貫,好像每個控制元件在獨自滑動。

在同樣的滑動條件下,實現了NestedScrollingChild2(NestedScrollView或RecyclerView等)與NestedScrollingParent2介面的巢狀滑動.看下面的例子:

NestedScrollingParent2效果展示

上述效果實現,請參看NestedScrollingParent2Layout.java

觀察上圖,我們能發現父控制元件與子控制元件(RecyclerView或NestedScrollView)的滑動更為順暢與合理。那接下來我們看看谷歌對其的設計。

NestedScrollingChild2與NestedScrollingParent2分別繼承了NestedScrollingChild與NestedScrollingParent,在繼承的介面部分方法上增加了type引數。其中type的取值為TYPE_TOUCH(0)TYPE_NON_TOUCH(1)。用於區分手勢滑動與fling。具體差異如下圖所示:

介面差異

圖片較大,可能閱讀不清晰,建議放大觀看。

谷歌在fling的處理上也與之前的NestedScrollingChild與NestedScrollingParent有所差異,在onTouchEvent方法中的邏輯進行了修改,虛擬碼如下所示:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();

        int y = (int) event.getY();
        int x = (int) event.getX();

        //新增速度檢測器,用於處理fling效果
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        switch (action) {
            case MotionEvent.ACTION_UP: {//當手指抬起的時,結束巢狀滑動傳遞,並判斷是否產生了fling效果
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                int xvel = (int) mVelocityTracker.getXVelocity();
                int yvel = (int) mVelocityTracker.getYVelocity();
                fling(xvel, yvel);//具體處理fling的方法
                mVelocityTracker.clear();
                stopNestedScroll(ViewCompat.TYPE_TOUCH));//注意這裡stop的是帶了引數的
                break;
            }

        }
        return super.onTouchEvent(event);
    }
複製程式碼

當子控制元件手指抬起的時候,我們發現是呼叫stopNestedScroll(ViewCompat.TYPE_TOUCH)的方式來通知父控制元件當前手勢滑動已經結束,繼續檢視fling方法。虛擬碼如下所示:

private boolean fling(int velocityX, int velocityY) {
        //判斷速度是否足夠大。如果夠大才執行fling
        if (Math.abs(velocityX) < mMinFlingVelocity) {
            velocityX = 0;
        }
        if (Math.abs(velocityY) < mMinFlingVelocity) {
            velocityY = 0;
        }
        if (velocityX == 0 && velocityY == 0) {
            return false;
        }
        if (dispatchNestedPreFling(velocityX, velocityY)) {
            boolean canScroll = canScroll();
            //將fling效果傳遞給父控制元件
            dispatchNestedFling(velocityX, velocityY, canScroll);

            //子控制元件在處理fling效果
            if (canScroll) {
                //通知父控制元件開始fling事件,
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                doFling(velocityX, velocityY);
                return true;
            }

        }
        return false;

    }
複製程式碼

從程式碼中,我們可以看見,在新介面的處理邏輯中,還是會呼叫dispatchNestedPreFling與dispatchNestedFling方法。也就是之前的處理fling方式是沒有被替代的,但是這並不說明沒有變化。我們發現子控制元件呼叫了startNestedScroll方法,並設定了當前型別為TYPE_NON_TOUCH(fling),那麼也就是說,在實現了NestedScrollingParent2的父控制元件中,我們可以在onStartNestedScroll方法中知道當前的滑動型別到底是fling,還是手勢滑動。我們繼續檢視doFling方法。虛擬碼如下:

    /**
     * 實際的fling處理效果
     */
    private void doFling(int velocityX, int velocityY) {
        mScroller.fling(0, 0, velocityX, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        postInvalidate();
    }

複製程式碼

doFling方法其實很簡單,就是呼叫OverScroller的fing方法,並呼叫postInvalidate方法(為了幫助大家理解,這裡並沒有採用 postOnAnimation()的方式)。其中OverScroller的fing方法主要是根據當前傳入的速度,計算出在勻減速情況下,實際運動的距離。這裡也就解釋了為什麼,在只有速度的情況下,子控制元件可以將fling傳遞給父控制元件,因為速度最後變成了實際的運動距離。

這裡就不對Scroller的fling方法中如何將速度轉換成距離的演算法進行講解了。不熟悉的小夥伴可以自行谷歌或百度。

熟悉Scroller的小夥伴一定知道,為了獲取到fling所產生的距離,我們需要呼叫postInvalidate()方法或Invalidate()方法。同時在子控制元件的computeScroll()方法中獲取實際的運動距離。那麼也就說最終的子控制元件的fing的分發實際是在computeScroll()方法中。繼續檢視該方法的虛擬碼:

 public void computeScroll() {
       if (mScroller.computeScrollOffset()) {
           int x = mScroller.getCurrX();
           int y = mScroller.getCurrY();
           int dx = x - mLastFlingX;
           int dy = y - mLastFlingY;

           mLastFlingX = x;
           mLastFlingY = y;
           //在子控制元件處理fling之前,先判斷父控制元件是否消耗
           if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, null, TYPE_NON_TOUCH)) {
               //計算父控制元件消耗後,剩下的距離
               dx -= mScrollConsumed[0];
               dy -= mScrollConsumed[1];

               //因為之前預設向父控制元件傳遞的豎直方向,所以這裡子控制元件也消耗剩下的豎直方向
               int hResult = 0;
               int vResult = 0;
               int leaveDx = 0;//子控制元件水平fling 消耗的距離
               int leaveDy = 0;//父控制元件豎直fling 消耗的距離

               if (dx != 0) {
                   leaveDx = childFlingX(dx);
                   hResult = dx - leaveDx;//得到子控制元件消耗後剩下的水平距離
               }
               if (dy != 0) {
                   leaveDy = childFlingY(dy);//得到子控制元件消耗後剩下的豎直距離
                   vResult = dy - leaveDy;
               }

               dispatchNestedScroll(leaveDx, leaveDy, hResult, vResult, null, TYPE_NON_TOUCH);

           }
       } else {
           //當fling 結束時,通知父控制元件
           stopNestedScroll(TYPE_NON_TOUCH);

       }
   }

複製程式碼

觀察程式碼,我們可以發現,子控制元件中分發fling的方式在與之前分發手勢滾動的邏輯非常一致。

  • 產生fing時,呼叫帶type(TYPE_NON_TOUCH)引數的dispatchNestedPreScroll方法,判斷父控制元件是否處理fling事件。
  • 如果父控制元件處理,那麼父控制元件消耗後,子控制元件再消耗剩餘的距離
  • 子控制元件消耗後,如果還有剩餘的距離,則呼叫帶type(TYPE_NON_TOUCH)引數的dispatchNestedScroll方法,將剩下的距離傳遞給父控制元件。
  • 當子控制元件fling結束時,則呼叫stopNestedScroll(TYPE_NON_TOUCH)方法,通知父控制元件fling已經結束。

那麼也就是說,NestedScrollingChild2與NestedScrollingParent2介面,只是在原有的方法中增加了TYPE_NON_TOUCH引數來讓父控制元件區分到底是手勢滑動還是fling。不得不佩服谷歌大佬的設計。不僅相容還解決了實際的問題。

總結

通過上文的分析,我們能得到如下結論:

  • NestedScrolling(巢狀滑動)機制是建立在原有的事件機制之上,要實現巢狀滑動,父控制元件是不能攔截事件。
  • NestedScrolling(巢狀滑動)機制中介面要成對使用。如NestedScrollingChild2與NestedScrollingParent2成對。NestedScrollingChild與NestedScrollingParent成對。
  • 當我們需要子控制元件分發fling給父控制元件時,我們需要使用NestedScrollingChild2與NestedScrollingParent2。並在相應的方法中通過type(TYPE_TOUCH(0)TYPE_NON_TOUCH(1)),來判斷是手勢滑動還是fling。

最後

到現在整個NestedScrolling(巢狀滑動)機制就講解完畢了,在接下來的文章中,會講解相應巢狀滑動例子、CoordinatorLayout與Behavior、自定義Behavior等相關知識點,如果大家有興趣的話,可以持續關注~。謝謝大家花時間閱讀文章啦。Thanks

相關文章