Android巢狀滑動邏輯淺析

Scus發表於2019-03-03

問題分析

巢狀滑動一直是Android中比較棘手的問題, 根本原因是Android的事件分發機制導致的.導致巢狀滑動難處理的關鍵原因在於當子控制元件消費了事件, 那麼父控制元件就不會再有機會處理這個事件了, 所以一旦內部的滑動控制元件消費了滑動操作, 外部的滑動控制元件就再也沒機會響應這個滑動操作了.

巢狀滑動

不過這個問題終於在LOLLIPOP(SDK21)之後終於有了官方的解決方法, 就是巢狀滑動機制. 在分析具體的程式碼邏輯之前, 下面先簡單介紹下巢狀滑動的一些基本知識.
巢狀滑動機制可以理解為一個約定, 原生的支援巢狀滑動的控制元件都是依據這個約定來實現巢狀滑動的, 例如CoordinatorLayout, 所以如果你自定義的控制元件也遵守這個約定, 那麼就可以跟原生的控制元件進行巢狀滑動了.

基本原理

巢狀滑動的基本原理是在子控制元件接收到滑動一段距離的請求時, 先詢問父控制元件是否要滑動, 如果滑動了父控制元件就通知子控制元件它消耗了一部分滑動距離, 子控制元件就只處理剩下的滑動距離, 然後子控制元件滑動完畢後再把剩餘的滑動距離傳給父控制元件.
通過這樣的巢狀滑動機制, 在一次滑動操作過程中

父控制元件和子控制元件都有機會對滑動操作作出響應, 尤其父控制元件能夠分別在子控制元件處理滑動距離之前和之後對滑動距離進行響應.

這解決了事件分發機制缺點引起的問題.

版本之別

在看具體的程式碼之前先說下巢狀滑動相關方法的一些我認為值得注意的地方.

LOLLIPOP(SDK21)之後

為什麼說這個是官方的解決方法? 因為

巢狀滑動的相關邏輯作為普通方法直接寫進了最新的(SDK21之後)ViewViewGroup類.

普通方法是指這個方法不是繼承自介面或者其他類, 例如View#dispatchNestedScroll, 可以看到官方標註了Added in API level 21標示, 也就是說這是在SDK21版本之後新增進去的一個普通方法.

向前相容

而SDK21之前的版本

官方在android.support.v4相容包中提供了兩個介面NestedScrollingChildNestedScrollingParent, 還有兩個輔助類NestedScrollingChildHelperNestedScrollingParentHelper來幫助控制元件實現巢狀滑動.

這個相容的原理很簡單

兩個介面NestedScrollingChildNestedScrollingParent分別定義上面提到的ViewViewParent新增的普通方法

在巢狀滑動中會要求控制元件要麼是繼承於SDK21之後的ViewViewGroup, 要麼實現了這兩個介面, 這是控制元件能夠進行巢狀滑動的前提條件.
那麼怎麼知道呼叫的方法是控制元件自有的方法, 還是介面的方法? 在程式碼中是通過ViewCompatViewParentCompat類來實現.

ViewCompatViewParentCompat通過當前的Build.VERSION.SDK_INT來判斷當前版本, 然後選擇不同的實現類, 這樣就可以根據版本選擇呼叫的方法.

例如如果版本是SDK21之前, 那麼就會判斷控制元件是否實現了介面, 然後呼叫介面的方法, 如果是SDK21之後, 那麼就可以直接呼叫對應的方法.

輔助類

除了介面相容包還提供了NestedScrollingChildHelperNestedScrollingParentHelper兩個輔助類, 這兩個輔助類實際上就是對應ViewViewParent中新增的普通方法, 程式碼就不貼了, 簡單對比下就可以發現, 對應方法實現的邏輯基本一樣, 所以

只要在介面方法內對應呼叫輔助類的方法就可以相容巢狀滑動了.

例如在NestedScrollingChild#startNestedScroll方法中呼叫NestedScrollingChildHelper#startNestedScroll.
題外話: 這裡實際用了代理模式來讓SDK21之前的控制元件具有了新增的方法.

預設處理邏輯

雖然ViewViewGroup(SDK21之後)本身就具有巢狀滑動的相關方法, 但是預設情況是是不會被呼叫, 因為ViewViewGroup本身不支援滑動, 所以

本身不支援滑動的控制元件即使有巢狀滑動的相關方法也不能進行巢狀滑動.

上面已經說到要讓控制元件支援巢狀滑動

  • 首先要控制元件類具有巢狀滑動的相關方法, 要麼僅支援SDK21之後版本, 要麼實現對應的介面, 為了相容低版本, 更常用到的是後者.

  • 因為預設的情況是不會支援滑動的, 所以控制元件要在合適的位置主動調起巢狀滑動的方法.

接下來通過分析相對簡單的支援巢狀滑動的容器NestedScrollView來了解下怎樣主動調起巢狀滑動的方法, 以及巢狀滑動的具體邏輯.

相關方法

先簡單看看相關方法的作用, 更具體的說明建議看原始碼註釋中的方法說明.
注意 : 下文分析用內控制元件表示兩層巢狀中的子控制元件, 外控制元件表示巢狀中的父控制元件.

NestedScrollingChild

startNestedScroll : 起始方法, 主要作用是找到接收滑動距離資訊的外控制元件.
dispatchNestedPreScroll : 在內控制元件處理滑動前把滑動資訊分發給外控制元件.
dispatchNestedScroll : 在內控制元件處理完滑動後把剩下的滑動距離資訊分發給外控制元件.
stopNestedScroll : 結束方法, 主要作用就是清空巢狀滑動的相關狀態
setNestedScrollingEnabledisNestedScrollingEnabled : 一對get&set方法, 用來判斷控制元件是否支援巢狀滑動.
dispatchNestedPreFlingdispatchNestedFling : 跟Scroll的對應方法作用類似, 不過分發的不是滑動資訊而是Fling資訊.(這個Fling好難翻譯.. =。=)本文主要關注滑動的處理, 所以後續不分析這兩個方法.

從方法名就可以看出

內控制元件是巢狀滑動的發起者.

NestedScrollingParent

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

外控制元件通過onNestedPreScrollonNestedScroll來接收內控制元件響應滑動前後的滑動距離資訊.

再次指出, 這兩個方法是實現巢狀滑動效果的關鍵方法.

從NestedScrollView看巢狀機制

說完上面一大通, 終於可以開始分析原始碼來了解巢狀滑動機制起作用的具體邏輯了.
NestedScrollView簡單地說就是支援巢狀滑動的ScrollView, 內部邏輯簡單, 而且它既可以是內控制元件, 也可以是外控制元件, 所以選擇分析它來了解巢狀滑動機制.

注意 : 因為NestedScrollingChildHelperNestedScrollingParent這兩個輔助類的實現跟ViewViewGroup中的對應方法是一樣的, 而且ViewViewGroup的原始碼沒有使用相容類, 所以下面分析相關方法的時候原始碼都使用ViewViewGroup中的程式碼.

上面已經說了巢狀滑動是從startNestedScroll開始, 所以先看看哪裡呼叫了這個方法, 在原始碼裡一搜就能知道有兩個地方呼叫了這個方法.

  • onInterceptTouchEventACTION_DOWN的情況

  • onTouchEventACTION_DOWN的情況

因為ACTION_DOWN是滑動操作的開始事件, 所以當接收到這個事件的時候嘗試找對應的外控制元件. 只有找到了外控制元件才有後續的巢狀滑動的邏輯發生.
關於NestedScrollView在這裡的實現其實有個奇怪的地方, 提出一個問題, 不感興趣的可以直接跳過這段.

  • 既然內控制元件是發起者, 為什麼要在onInterceptTouchEvent也呼叫startNestedScroll呢?

因為事件傳遞的時候會先執行外控制元件的onInterceptTouchEvent, 也就是說第一個執行startNestedScroll的是最外層的NestedScrollView, 即使它找到了對應的外控制元件後續如果有子控制元件消費了這個事件, 也就是說不執行onTouchEvent方法, 那麼找到外控制元件也沒用的, 不清楚設計者的意圖.

接著我們看startNestedScroll是如何找對應的外控制元件的, 因為NestedScrollView#startNestedScroll呼叫了輔助方法的startNestedScroll, 所以下面直接貼View#startNestedScroll.

// View.javapublic 
boolean startNestedScroll(int axes) { 
    // ... 
    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) { 
                // ... 
            } 
            if (p instanceof View) { 
                child = (View) p; 
            } 
            p = p.getParent(); 
        } 
    } 
    return false;
}複製程式碼

非常簡單的邏輯遍歷父控制元件, 呼叫父控制元件的onStartNestedScroll, 返回true表示找到了對應的外控制元件, 找到外控制元件後馬上呼叫onNestedScrollAccepted

從這裡可以知道

外控制元件不一定是內控制元件的直接父控制元件, 但一定是最近的符合條件的外控制元件.

還可以確定了上面關於onStartNestedScroll的方法說明, 返回true表示接收內控制元件的滑動資訊.對於NestedScrollView#onStartNestedScroll內部邏輯很簡單, 只要是豎直滑動方向就返回true, 所以可以知道

NestedScrollView不支援橫向巢狀滑動.

接著被呼叫的是onNestedScrollAccepted, 看NestedScrollView#onNestedScrollAccepted

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

輔助類的方法很簡單, 就是記錄當前的滑動方向, 在這裡NestedScrollView又呼叫startNestedScroll來找它自己的外控制元件, 這是為了連續巢狀NestedScrollView, 不過這是NestedScrollView自己的實現, 不管它.

找到了外控制元件後ACTION_DOWN事件就沒巢狀滑動的事了, 要滑動肯定會在onTouchEvent中處理ACTION_MOVE事件, 接著我們看ACTION_MOVE事件是怎樣處理的.

// NestedScrollView#onTouchEvent
case MotionEvent.ACTION_MOVE: 
    // ... 
    final int y = (int) MotionEventCompat.getY(ev, activePointerIndex); 
    int deltaY = mLastMotionY - y; 
    // 讓外控制元件先處理滑動距離 
    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) { 
        deltaY -= mScrollConsumed[1];// 消耗滑動距離 
        // ... 
    } 
    // ... 
    if (mIsBeingDragged) { 
        // ... 
        // 內控制元件處理滑動距離 
        if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0, 
                      0, true) && !hasNestedScrollingParent()) { 
            // ... 
        } 

        final int scrolledDeltaY = getScrollY() - oldY; 
        final int unconsumedY = deltaY - scrolledDeltaY; 
        if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) { 
            // ... 
        } 
        // ... 
    } 
    break;複製程式碼

這部分是NestedScrollView能夠處理巢狀滑動的關鍵程式碼了, 其他能夠巢狀滑動的控制元件也應該在ACTION_MOVE中類似地處理滑動距離.

先計算出本次滑動距離deltaY, 這裡有個小細節

deltaY等於上一次的Y座標減去這次的Y座標, 這意味著在相關方法中接收到的滑動距離引數中, 滑動距離 > 0表示手指向下滑動, 反之表示手指向上滑動. 這是因為在螢幕中Y軸正方向是向下的.

得到滑動距離deltaY後, 先把它傳給dispatchNestedPreScroll, 然後在結果返回true的時候, delta會減去mScrollConsumed[1].

接著看dispatchNestedPreScroll幹了什麼

// View.java
public boolean dispatchNestedPreScroll(int dx, int dy,
                     @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
    // ... 忽略狀態判斷 
    consumed[0] = 0; 
    consumed[1] = 0; 
    mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed); 
    return consumed[0] != 0 || consumed[1] != 0; 
    // 其他情況返回false
}複製程式碼

忽略條件判斷和offsetInWindow的相關處理, 先指出consumed就是上一步分析中的mScrollConsumed, dy就是deltaY.
因為dispatchNestedPreScroll的工作就是把滑動距離在內控制元件處理前分發給外控制元件, 所以這裡的關鍵程式碼也很簡單, 就是直接把相關的引數傳給外控制元件的onNestedPreScroll, 然後只要外控制元件消耗了滑動距離(不論橫向還是豎向), 就會返回true

所以

外控制元件如果想在內控制元件之前消耗滑動距離僅需要在onNestedPreScroll把消耗的值放到陣列中返回給內控制元件.

onNestedPreScroll是決定外控制元件的巢狀滑動邏輯的關鍵方法, 在不同的控制元件中應該是根據需要有不同的實現的, 而在NestedScrollView中就是直接詢問它自己的外控制元件是否消耗滑動距離, 實現比較簡單就不貼程式碼了.

在這裡提醒下, 在我們自己修改巢狀滑動邏輯的時候需要注意滑動距離的正負號和內控制元件處理consumed陣列的方式. 不過這些都是些數字遊戲, 不細說了.

好了, 現在外控制元件已經比內控制元件先處理了滑動距離了, 如果外控制元件沒有完全消耗掉所有滑動距離, 這時該內控制元件處理剩下的滑動距離了, 不同的控制元件有不同的滑動實現, 在NestedScrollView中通過NestedScrollView#overScrollByCompat來進行滑動, 並且滑動結束後通過比對滑動前後的scrollY值得到了內控制元件消耗的滑動距離, 然後得到剩下的滑動距離, 最後傳給dispatchNestedScroll.

dispatchNestedScroll的邏輯跟dispatchNestedPreScroll幾乎一樣, 區別是它呼叫了外控制元件的onNestedScroll, 因為到這裡已經是處理滑動距離最後的機會了, 所以onNestedScroll不會再影響內控制元件的處理邏輯了.

到這裡ACTION_MOVE事件就分析完畢了.

最後就是stopNestedScroll了, 程式碼就不貼了, 呼叫這個方法基本是新的滑動操作開始前, 或者滑動操作結束/取消, 程式碼邏輯就是進行一些變數的重置工作和呼叫onStopNestedScroll, 而onStopNestedScroll也類似.

整個巢狀滑動的基本邏輯就是這樣. 注意這裡雖然分析的是NestedScrollView, 但這代表了巢狀滑動的"約定"處理方式, 雖然不同的控制元件實際的實現會有不同不過應該遵循基本方法的呼叫順序, 確保引數的含義和引數的處理方式.

總結

  • 如果要支援巢狀滑動, 內控制元件和外控制元件要支援對應的方法, 為了相容低版本一般通過實現NestedScrollingChildNestedScrollingParent介面以及使用NestedScrollingChildHelperNestedScrollingParent輔助類.

  • 具體巢狀滑動邏輯主要是在onNestedPreScrollonNestedScroll方法中.

  • 父控制元件通過給陣列賦值來把消耗的滑動距離傳遞給內控制元件.


感謝原創作者的獨到的剖析!!

本文轉載自:http://www.apkbus.com/blog-977752-79583.html


相關文章