【透鏡系列】看穿 > NestedScrolling 機制 >

RubiTree發表於2019-01-14

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

NestedScrolling 機制翻譯過來叫巢狀滑動機制(本文將混用),它提供了一種優雅解決巢狀滑動問題的方案,具體是什麼方案呢?我們從巢狀的同向滑動說起。

1. 巢狀同向滑動

1.1. 巢狀同向滑動的問題

所謂巢狀同向滑動,就是指這樣一種情況:兩個可滑動的View內外巢狀,而且它們的滑動方向是相同的。

-w350

這種情況如果使用一般的處理方式,會出現互動問題,比如使用兩個ScrollView進行佈局,你會發現,觸控著內部的ScrollView進行滑動,它是滑不動的 (不考慮後來 Google 給它加的NestedScroll開關)

【透鏡系列】看穿 > NestedScrolling 機制 >

1.2. 分析問題原因

(溫馨提示:本文涉及事件分發的內容比較多,建議對事件分發不太熟悉的同學先閱讀另一篇透鏡《看穿 > 觸控事件分發》

如果你熟悉 Android 的觸控事件分發機制,那麼原因很好理解:兩個ScrollView巢狀時,滑動距離終於達到滑動手勢判定閾值(mTouchSlop)的這個MOVE事件,會先經過父 View 的onInterceptTouchEvent()方法,父 View 於是直接把事件攔截,子 View 的onTouchEvent()方法裡雖然也會在判定滑動距離足夠後呼叫requestDisallowInterceptTouchEvent(true),但始終要晚一步。

而這個效果顯然是不符合使用者直覺的 那使用者希望看到什麼效果呢?

  1. 大部分時候,使用者希望看到:當手指觸控內部ScrollView進行滑動時,能先滑動內部的ScrollView,只有當內部的ScrollView滑動到盡頭時,才滑動外部的ScrollView

這看上去非常自然,也跟觸控事件的處理方式一致,但相比觸控事件的處理,要在滑動時實現同樣的效果卻會困難很多

  1. 因為滑動動作不能立刻識別出來,它的處理本身就需要通過事件攔截機制進行,而事件攔截機制實質上跟《看穿 > 觸控事件分發》中第一次試造的輪子一樣,只是單向的,而且方向從外到內,所以無法做到:先讓內部攔截滑動,內部不攔截滑動時,再在讓外部攔截滑動

那能不能把事件攔截機制變成雙向的呢?不是不行,但這顯然違背了攔截機制的初衷,而且它很快會發展成無限遞迴的:雙向的事件攔截機制本身是否也需要一個攔截機制呢?於是有了攔截的攔截,然後再有攔截的攔截的攔截...

-w150

1.3. 嘗試解決問題

換一個更直接的思路,如果我們的需求始終是內部滑動優先,那是否可以讓外部 View「攔截滑動的判定條件」比內部 View「申請外部不攔截的判定條件」更嚴格,從而讓滑動距離每次都先達到「申請外部不攔截的判定條件」,子 View 就能夠在父 View 攔截事件前申請外部不攔截了。 能看到在ScrollView中,「攔截滑動的判定條件」和「申請外部不攔截的判定條件」都是Math.abs(deltaY) > mTouchSlop,我們只需要增大「攔截滑動的判定條件」時的mTouchSlop就行了。

但實際上這樣做並不好,因為mTouchSlop到底應該增加多少,是件不確定的事情,手指滑動的快慢和螢幕的解析度可能都會對它有影響。 所以可以換一種實現,那就是讓第一次「攔截滑動的判定條件」成立時,先不進行攔截,如果內部沒有申請外部不攔截,第二次條件成立時,再進行攔截,這樣也同樣實現了開始的思路。 於是繼承 ScrollView,覆寫它的onInterceptTouchEvent()

class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
    private var isFirstIntercept = true
    
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            isFirstIntercept = true
        }

        val result = super.onInterceptTouchEvent(ev)

        if (result && isFirstIntercept) {
            isFirstIntercept = false
            return false
        }

        return result
    }
}    
複製程式碼

它的效果是這樣,能看到確實實現了讓內部先獲取事件:

【透鏡系列】看穿 > NestedScrolling 機制 >

1.4. 第一次優化

但我們希望體驗能更好一點,從上圖能看到,內部即使在自己無法滑動的時候,也會對事件進行攔截,無法通過滑動內部來讓外部滑動。其實內部應該在自己無法滑動的時候,直接在onTouchEvent()返回false,不觸發「申請外部不攔截的判定條件」,就能讓內外都有機會滑動。 這個要求非常通用而且合理,在SimpleNestedScrollView基礎上進行簡單修改,加上以下程式碼:

private var isNeedRequestDisallowIntercept: Boolean? = null

override fun onTouchEvent(ev: MotionEvent): Boolean {
    if (ev.actionMasked == MotionEvent.ACTION_DOWN) isNeedRequestDisallowIntercept = null
    if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
        if (isNeedRequestDisallowIntercept == false) return false

        if (isNeedRequestDisallowIntercept == null) {
            val offsetY = ev.y.toInt() - getInt("mLastMotionY")
            if (Math.abs(offsetY) > getInt("mTouchSlop")) { // 滑動距離足夠判斷滑動方向是上還是下後
                // 判斷自己是否能在對應滑動方向上進行滑動(不能則返回false)
                if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
                    isNeedRequestDisallowIntercept = false
                    return false
                }
            }
        }
    }

    return super.onTouchEvent(ev)
}

private fun isScrollToTop() = scrollY == 0

private fun isScrollToBottom(): Boolean {
    return scrollY + height - paddingTop - paddingBottom == getChildAt(0).height
}
複製程式碼
  1. 其中getInt("mLastMotionY")getInt("mTouchSlop")為反射程式碼,獲取私有的mLastMotionYmTouchSlop屬性
  2. 這段程式碼省略了多點觸控情況的判斷

執行效果如下:

【透鏡系列】看穿 > NestedScrolling 機制 >

這樣就完成了對巢狀滑動View最基本的需求:大家都能滑了。

後來我發現了一種更野的路子,不用小心翼翼地讓改動儘量小,既然內部優先,完全可以讓內部的ScrollViewDOWN事件的時候就申請外部不攔截,然後在滑動一段距離後,如果判斷自己在該滑動方向無法滑動,再取消對外部的攔截限制,思路是類似的但程式碼更簡單。

class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) parent.requestDisallowInterceptTouchEvent(true)
        
        if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
            val offsetY = ev.y.toInt() - getInt("mLastMotionY")

            if (Math.abs(offsetY) > getInt("mTouchSlop")) {
                if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
        
        return super.dispatchTouchEvent(ev)
    }
}
複製程式碼

執行的效果跟上面是一樣的,不重複貼圖了。

1.5. 第二次優化

但這兩種方式目前為止都沒有實現最好的互動體驗,最好的互動體驗應該讓內部不能滑動時,能接著滑動外部,甚至在你滑動過程中快速抬起時,接下來的慣性滑動也能在兩個滑動View間傳遞。

由於滑動這個互動的特殊性,我們可以在外部對它進行操作,所以連續滑動的實現非常簡單,只要重寫scrollBy就好了,所以在已有程式碼的基礎上再加上下面的程式碼(上面的兩種思路都是加一樣的程式碼):

override fun scrollBy(x: Int, y: Int) {
    if ((y > 0 && isScrollToTop()) || (y < 0 && isScrollToBottom())) {
        (parent as View).scrollBy(x, y)
    } else {
        super.scrollBy(x, y)
    }
}
複製程式碼

效果如下:

【透鏡系列】看穿 > NestedScrolling 機制 >

而慣性滑動的實現就會相對複雜一點,得對computeScroll()方法下手,要做的修改會多一些,這裡暫時不去實現了,但做肯定是沒問題的。

1.6. 小結

到這裡我們對巢狀滑動互動的理解基本已經非常通透了,知道了讓我們自己實現也就那麼回事,主要需要解決下面幾個問題:

  1. 在內部 View 可以滑動的時候,阻止外部 View 攔截滑動事件,先滑動內部 View
  2. 在使用者一次滑動操作中,當內部 View 滑動到終點時,切換滑動物件為外部 View,讓使用者能夠連續滑動
  3. 在使用者快速抬起觸發的慣性滑動中,當內部 View 滑動到終點時,切換滑動物件為外部 View,讓慣效能夠連續

這時就可以來看看看系統提供的 NestedScrolling 機制是怎麼完成巢狀滑動需求的,跟我們的實現相比,有什麼區別,是更好還是更好?

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

2. NestedScrolling 機制

2.1. 原理

與我們不同,我們只考慮了給ScrollView增加支援巢狀滑動的特性,但系統開發者需要考慮給所有有滑動互動的 View 增加這個特性,所以一個直接的思路是在 View 里加入這個機制。

那麼要怎麼加,加哪些東西呢?

  1. 進一步梳理前面要解決的問題,在巢狀滑動中,是能明確區分兩類作用物件的:一個是內部 View,一個是外部 View。而且它們的主被動關係也非常明確:因為內部 View 離手指更近,我們肯定希望它能優先消費事件,但我們同時還希望在某些情況下事件能在內部不消耗的時候給外部消耗,這當然也是讓內部來控制,所以內部是主動,外部是被動回到空氣馬達
  2. 由此整個巢狀滑動的過程可以認為是這樣的:觸控事件交給內部 View 進行消費,內部 View 執行相關邏輯,在合適的時候對外部 View 進行一定的控制,兩者配合實現巢狀滑動
  3. 這就包括了兩部分邏輯:
    1. 內部 View 中的主動邏輯:需要主動阻止外部 View 攔截事件,需要自己進行滑動,並在合適的時候讓外部 View 配合進行剩下的滑動
      1. 這部分是核心內容,前面我們自己實現的也是這部分內容
    2. 外部 View 中的被動邏輯
      1. 基本就是配合行動了,這部分邏輯不多
  4. 由於View裡是不能放其他View的,它只能是內部的、主動的角色,而ViewGroup既可以放在另一ViewGroup裡,它裡邊也可以放其他的View,所以它可以是內部的也可以是外部的角色
  5. 這正好符合ViewViewGroup的繼承關係,所以一個很自然的設計是:在View中加入主動邏輯,在ViewGroup中加入被動邏輯

因為不是每個ViewViewGroup都能夠滑動,滑動只是眾多互動中的一種,ViewViewGroup不可能直接把所有事情都做了然後告訴你:Android 支援巢狀滑動了哦~ 所以 Google 加入的這些邏輯其實都是幫助方法,相關的View需要選擇在合適的時候進行呼叫,最後才能實現巢狀滑動的效果。

先不說加了哪些方法,先說 Google 希望能幫助你實現一個什麼樣的巢狀滑動效果:

  1. 從邏輯上區分巢狀滑動中的兩個角色:ns childns parent,對應了上面的內部 View 和外部 View
    1. 注:1)這裡我用「ns」表示nested scroll的縮寫;2)為什麼叫邏輯上?因為實際上它允許你一個 View 同時扮演兩個角色
  2. ns child會在收到DOWN事件時,找到自己祖上中最近的能與自己匹配的ns parent,與它進行繫結並關閉它的事件攔截機制
  3. 然後ns child會在接下來的MOVE事件中判定出使用者觸發了滑動手勢,並把事件流攔截下來給自己消費
  4. 消費事件流時,對於每一次MOVE事件增加的滑動距離:
    1. ns child並不是直接自己消費,而是先把它交給ns parent,讓ns parent可以在ns child之前消費滑動
    2. 如果ns parent沒有消費或是沒有消費完,ns child再自己消費剩下的滑動
    3. 如果ns child自己還是沒有消費完這個滑動,會再把剩下的滑動交給ns parent消費
    4. 最後如果滑動還有剩餘,ns child可以做最終的處理
  5. 同時在ns childcomputeScroll()方法中,ns child也會把自己因為使用者fling操作引發的滑動,與上一條中使用者滑動螢幕觸發的滑動一樣,使用「parent -> child -> parent -> child」的順序進行消費

注:

  1. 以上過程參考當前最新的androidx.core 1.1.0-alpha01中的NestedScrollViewandroidx.recyclerView 1.1.0-alpha01中的RecyclerView實現,與之前的版本細節略有不同,後文會詳述其中差異
  2. 為了理解上的方便,有幾處細節的描述做了簡化:其實在NestedScrollViewRecyclerView這類經典實現中: 1. 在 ns child 滾動時,只要使用者手指一按下,ns child 就會攔截事件流,不用等到判斷出滑動手勢(具體可以關注原始碼中的 mIsBeingDragged 欄位) 1. 這個細節是合理的,會讓使用者體驗更好 2. (後文將不會對這個細節再做說明,而是直接用簡化的描述,實現時如果要提高使用者體驗,需要注意這個細節) 1. 按照 Android 的觸控事件分發規則,如果 ns child 內部沒有要消費事件的 View,事件也將直接交給 ns childonTouchEvent() 消費。這時在 NestedScrollViewns child 的實現中,接下來onTouchEvent() 裡判斷出使用者是要滑動自己之前,就會把使用者的滑動交給 ns parent 進行消費回到4.4 1. 這個設計我個人覺得不太合理,既然是傳遞滑動那就應該在判斷出使用者確實在滑動之後才開始傳遞,而不是這樣直接傳遞,而且在後文的實踐部分,你確實能看到這種設計帶來的問題 1. (後文的描述中如果沒有特別說明,也是預設忽略這個細節)
  3. 描述中省略了關於直接傳遞 fling 的部分,因為這塊的設計存在問題,而且最新版本這部分機制的作用已經非常小了,後面這點會詳細講

你會發現,這跟我們自己實現巢狀滑動的方式非常像,但它有這些地方做得更好(具體怎麼實現的見後文)

  1. ns child使用更靈活的方式找到和繫結自己的ns parent,而不是直接找自己的上一級結點
  2. ns childDOWN事件時關閉ns parent的事件攔截機制單獨用了一個 Flag 進行關閉,這就不會關閉ns parent對其他手勢的攔截,也不會遞迴往上關閉祖上們的事件攔截機制。ns child直到在MOVE事件中確定自己要開始滑動後,才會呼叫requestDisallowInterceptTouchEvent(true)遞迴關閉祖上們全部的事件攔截
  3. 對每一次MOVE事件傳遞來的滑動,都使用「parent -> child -> parent -> child」機制進行消費,讓ns child在消費滑動時與ns parent配合更加細緻、緊密和靈活
  4. 對於因為使用者fling操作引發的滑動,與使用者滑動螢幕觸發的滑動使用同樣的機制進行消費,實現了完美的慣性連續效果

2.2. 使用

到這一步,我們再來看看 Google 給 View 和 ViewGroup 加了哪些方法?又希望我們什麼時候怎麼去呼叫它們?

加入的需要你關心的方法一共有這些(只註明了關鍵返回值和引數,參考當前最新的版本 androidx.core 1.1.0-alpha01):

// 『View』
setNestedScrollingEnabled(true)                       // 呼叫
startNestedScroll()                                   // 呼叫
dispatchNestedPreScroll(int delta, int[] consumed)    // 呼叫
dispatchNestedScroll(int unconsumed, int[] consumed)  // 呼叫
stopNestedScroll()                                    // 呼叫

// 『ViewGroup』
boolean onStartNestedScroll()                       // 覆寫
int getNestedScrollAxes()                           // 呼叫
onNestedPreScroll(int delta, int[] consumed)        // 覆寫
onNestedScroll(int unconsumed, int[] consumed)      // 覆寫
複製程式碼

怎麼呼叫這些方法取決於你要實現什麼角色

  1. 在你實現一個ns child角色時,你需要:
    1. 在例項化的時候呼叫setNestedScrollingEnabled(true),啟用巢狀滑動機制
    2. DOWN事件時呼叫startNestedScroll()方法,它會「找到自己祖上中最近的與自己匹配的ns parent,進行繫結並關閉ns parent的事件攔截機制」
    3. 在判斷出使用者正在進行滑動後
      1. 先常規操作:關閉祖上們全部的事件攔截,同時攔截自己子 View 的事件
      2. 然後呼叫dispatchNestedPreScroll()方法,傳入使用者的滑動距離,這個方法會「觸發ns parent對滑動的消費,並且把消費結果返回」
      3. 然後ns child可以開始自己消費剩下滑動
      4. ns child自己消費完後呼叫dispatchNestedScroll()方法,傳入最後沒消費完的滑動距離,這個方法會繼續「觸發ns parent對剩下滑動的消費,並且把消費結果返回」
      5. ns child拿到最後沒有消費完的滑動,做最後的處理,比如顯示 overscroll 效果,比如在 fling 的時候停止scroller
    4. 如果你希望慣性滑動也能傳遞給ns parent,那麼在ViewcomputeScroll()方法中,對於每個scroller計算到的滑動距離,與MOVE事件中處理滑動一樣,按照這個順序進行消費:「dispatchNestedPreScroll() -> 自己 -> dispatchNestedScroll() -> 自己」
    5. UPCANCEL事件中以及computeScroll()方法中慣性滑動結束時,呼叫stopNestedScroll()方法,這個方法會「開啟ns parent的事件攔截機制,並取消與它的繫結」
  2. 在你實現一個ns parent角色時,你需要:
    1. 重寫方法boolean onStartNestedScroll(View child, View target, int nestedScrollAxes),通過傳入的引數,決定自己對這類巢狀滑動感興趣,在感興趣的情況中返回truens child就是通過遍歷所有ns parent的這個方法來找到與自己匹配的ns parent
    2. 如果選擇了某種情況下支援巢狀滑動,那麼在攔截滑動事件前,呼叫getNestedScrollAxes(),它會返回你某個方向的攔截機制是否已經被ns child關閉了,如果被關閉,你就不應該攔截事件了
    3. 開啟巢狀滑動後,你可以在onNestedPreScrollonNestedScroll方法中耐心等待ns child的訊息,沒錯,它就對應了你在ns child中呼叫的dispatchNestedPreScrolldispatchNestedScroll方法,你可以在有必要的時候進行自己的滑動,並且把消耗掉的滑動距離通過引數中的陣列返回

這麼實現的例子可以看 ScrollView,只要開啟它的setNestedScrollingEnabled(true)開關,你就能看到巢狀滑動的效果:(實際上ScrollView實現的不是完美的巢狀滑動,原因見下一節)

【透鏡系列】看穿 > NestedScrolling 機制 >

ns parent還好,但ns child的實現還會有大量的細節(包括實踐部分會提到的「ns parent偏移導致的 event 校正」等等),光是描述可能不夠直接,為此我也為ns child準備了一份參考模板:NestedScrollChildSample

注意

  1. 雖然模板在IDE裡不會報錯,但這不是可以執行的程式碼,這是剔除 NestedScrollView 中關於 ns parent 的部分,得到的可以認為是官方推薦的 ns child 實現
  2. 同時,為了讓主線邏輯更加清晰,刪去了多點觸控相關的邏輯,實際開發如果需要,可以直接參考 NestedScrollView 中的寫法,不會麻煩太多*(有空會寫多點觸控的透鏡系列XD)*
  3. 其中的關鍵部分是在觸控和滾動時怎麼呼叫 NestedScrollingChild 介面的方法,也就是 onInterceptTouchEvent()onTouchEvent()computeScroll() 中大約 200 行的程式碼

另外,以上都說的是單一角色時的使用情況,有時候你會需要一個 View 扮演兩個角色,就需要再多做一些事情,比如對於ns parent,你要時刻注意你也是 ns child,在來生意的時候也照顧一下自己的ns parent,這些可以去看 NestedScrollView 的實現,不在這展開了。

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

3. 歷史的消防車滾滾向前

但是有人就了:回到答案

  1. 我怎麼看到別人講,你必須實現NestedScrollingParentNestedScrollingChild這兩個介面,然後利用上NestedScrollingParentHelperNestedScrollingChildHelper這兩個幫助類,才能實現一個支援巢狀滑動的自定義 View 啊,而且大家都稱讚這是一種很棒的設計呢,怎麼到你這就變成了直接加在View和 ViewGroup 裡的方法了,這麼普通的 DISCO 嘛?而且題圖裡也看到有這幾個介面的啊,你難道是標題黨嗎?(贊一個居然還記得題圖)
  2. 為什麼不用實現介面也能實現巢狀滑動,又為什麼幾乎所有實現巢狀滑動的 View 又都實現了這兩個介面呢?
  3. 為什麼明明巢狀滑動機制在NestedScrollingParentNestedScrollingChild這兩個介面裡放了那麼多方法,你卻只講9個呢?
  4. 為什麼介面裡的 fling 系列方法你不講?
  5. 為什麼有NestedScrollingChild,有NestedScrollingChild2,工作不飽和的同學會發現最近 Google 還增加了NestedScrollingChild3,這都是在幹哈?改了些什麼啊?

彆著急,要解釋這些問題,還得先來了解下歷史,翻翻sdksupport library家的老黃曆: (嫌棄太長也可以直接前往觀看小結(事情要從五年前說起...)

3.1. 第一個版本,2014年9月

Android 5.0 / API 21 (2014.9) 時, Google 第一次加入了 NestedScrolling 機制。

雖然在版本更新裡完全沒有提到,但是在ViewViewGroup 的原始碼裡你已經能看到其中的巢狀滑動相關方法。 而且此時使用了這些方法實現了巢狀滑動效果的 View 其實已經有不少了,除了我們講過的ScrollView,還有AbsListViewActionBarOverlayLayout等,而這些也基本是當時所有跟滑動有關的 View 了。 所以,如上文巢狀ScrollView的例子所示,在Android 5.0時大家其實就能通過setNestedScrollingEnabled(true)開關啟用 View 的巢狀滑動效果。

這是 NestedScrolling 機制的第一版實現。

3.2. 重構第一個版本,2015年4月

因為第一個版本的 NestedScrolling 機制是加在 framework 層的 View 和 ViewGroup 中,所以能享受到巢狀滑動效果的只能是Android 5.0的系統,也就是當時最新的系統。 大家都知道,這樣的功能不會太受開發者待見,所以在當時 NestedScrolling 機制基本沒有怎麼被使用。(所以大家一說巢狀滑動就提後來才釋出的NestedScrollView而不不知道ScrollView早就能巢狀滑動也是非常正常了)

Google 就覺得,這可不行啊,巢狀滑不動的Bug不能老留著啊 好東西得大家分享啊,於是一狠心,梳理了下功能,重構出來兩個介面(NestedScrollingChildNestedScrollingParent)兩個 Helper (NestedScrollingChildHelperNestedScrollingParentHelper)外加一個開箱即用的NestedScrollView,在 Revision 22.1.0 (2015.4) 到來之際,把它們一塊加入了v4 support library豪華午餐。

這下大夥就開心了,奔走相告:巢狀滑動卡了嗎,趕緊上NestedScrollView吧,Android 1.6也能用。 同時NestedScrollingChildNestedScrollingParent也被大家知曉了,要自己整個巢狀滑動,那就實現這兩介面吧。

隨後,在下一個月 Revision 22.2.0 (2015.5)時,Google又隆重推出了 Design Support library,其中的殺手級控制元件CoordinatorLayout更是把 NestedScrolling 機制玩得出神入化。

NestedScrolling 機制終於走上臺前,一時風頭無兩。

但注意,我比較了一下,這時的 NestedScrolling 機制相比之前放在 View 和 ViewGroup 中的第一個版本,其實完全沒有改動,只是把 View 和 ViewGroup 裡的方法分成兩部分放到介面和 Helper 裡了,NestedScrollView裡跟巢狀滑動有關的部分也跟ScrollView裡的沒什麼區別,所以此時的 NestedScrolling 機制本質還是第一個版本,只是形式發生了變化。

而 NestedScrolling 機制形式的變化帶來了什麼影響呢?

  1. 把 NestedScrolling 機制從 View 和 ViewGroup 中剝離,把有關的 API 放在介面中,把相關實現放在 Helper 裡,讓每一個普通的低版本的 View 都能享受到巢狀滑動帶來的樂趣,這就是它存在的意義啊(誤
  2. 確實,因為這個機制其實不涉及核心的 framework 層的東西,所以讓它脫離 API 版本存在,讓低版本系統也能有巢狀滑動的體驗,才是導致這個變化的主要原因也是它的主要優點。至於依賴倒置、組合大於繼承應該都只是結果。而便於修復 Bug(×2) 什麼的 Google 當時大概也沒有想到。
  3. 同時,這麼做肯定也不止有有優點,它也會有缺點,否則一開始就不會直接把機制加到 View 和 ViewGroup 裡了,它的主要缺點有:
    1. 使用麻煩。這是肯定的,本來放在 View 裡拿來就用的方法,現在不僅要實現介面,還要自己去寫介面的實現,雖然有 Helper 類進行輔助,但還是麻煩啊
    2. 暴露了更多內部的不需要普通使用者關心的 API。這點我認為比上一點要重要一些,因為它會影響開發者對整個機制的上手速度。本來,如我前文介紹,你只需要知道有這9個方法就行,現在這一改,光 child 裡就有9個,parent 裡還有8個,接近 double 了。多的這些方法中有的是機制內部用來溝通的(比如isNestedScrollingEnabled()onNestedScrollAccepted()),有的是設計彆扭用得很少的(比如dispatchNestedFling()),有的是需要特別優化細節才需要的(比如hasNestedScrollingParent()),一開始開發者其實完全不用關心。

3.2.1. 第一個版本的Bug

Android 1.6也用上了巢狀滑動,老奶奶開心得合不攏嘴。但大家用著用著,新鮮感過去之後,也開始不滿足了起來,於是就有了第一版 NestedScrolling 機制的著名Bug:「慣性不連續」回到小結

什麼是慣性不連續?如下圖

【透鏡系列】看穿 > NestedScrolling 機制 >

簡單說就是:你在滑動內部 View 時快速抬起手指,內部 View 會開始慣性滑動,當內部 View 慣性滑動到自己頂部時便停止了滑動,此時外部的可滑動 View 不會有任何反應,即使外部 View 可以滑動。 本來這個體驗也沒多大問題,但因為你手動滑動的時候,內部滑動到頂部時可以接著滑動外邊的 View,這就形成了對比,有對比就有差距,有差距群眾就不滿意了,你不能在慣性滑動的時候也把裡面的滑動傳遞到外面去嗎? 所以這個問題也不能算是 Bug,只是體驗沒有做到那麼好罷了。

其實 Google 不是沒有考慮過慣性,其中關於 fling 的4個 API 更是存在感十足地告訴大家,我就是來處理你們說的這檔子事的,但為什麼還是有 Bug 呢,那就不得不提這4個 API 的奇葩設計和用法了。

這四個 API 長這樣,看名字對應上 scroll 的4個 API 大概能知道是幹什麼的(但實際上有很大區別,見下文):

  1. ns child:dispatchNestedPreFlingdispatchNestedFling
  2. ns parent:onNestedPreFlingonNestedFling

前面我在講述的時候預設是讓ns child直接消費使用者快速抬起時產生的慣性滑動,這沒有什麼問題,因為我們還在computeScroll方法中把慣性引起的滑動也傳遞給了ns parent,讓父子配合進行慣性滑動。 但實際上此時的NestedScrollView是這麼寫的:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll();
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        if (canFling) fling(velocityY);
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() > 0) {
        ...
    
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0, Math.max(0, bottom - height), 0, height/2);
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        ... // 沒有關於把滑動分發給 ns parent 的邏輯
    }
}
複製程式碼

來讀一下其中的邏輯

  1. 首先看 API ,同滑動一樣,設計者給慣性(速度)也設計了一套協同消費的機制,但是這套機制與滑動不太一樣,或者說完全不同
  2. 在使用者滑動ns child並快速抬起手指產生慣性的時候,看flingWithNestedDispatch()方法,ns child會先問ns parent是否消費此速度
    1. 如果消費,就把速度全部交出,自己不再消費
    2. 如果ns parent不消費,那麼將再次把速度交給ns parent,並且告訴它自己是否有消費速度的條件*(根據系統類庫一貫的寫法,如果ns child消費這個速度,ns parent都不會對這個速度做處理)*,同時自己在有消費速度的條件時,對速度進行消費
  3. 自己消費速度的方式是使用mScroller進行慣性滑動,但是在computeScroll()中並沒有把滑動分發給 ns parent
  4. 最後只要抬起手指,就會呼叫stopNestedScroll()解除與ns parent的繫結,宣告這次協同合作到此結束

那麼總結一下:

  1. 慣性的這套協同消費機制只能在慣性滑動前讓ns parent有機會攔截處理慣性,它並不能在慣性滑動過程中讓ns childns parent協同消費慣性引發的滑動,也就是實現不了前面人們期望的慣性連續效果,所以第一版的開發者想用直接傳遞慣性的方式實現慣性連續可能不是個好主意
    1. 另外,目前慣性的協同消費機制只會在ns child無法進行滑動的時候起到一定的作用(雖然完全可以用滑動的協同消費機制替代),而在之後的版本中,這個作用基本也沒有被用到,它確實被滑動的協同消費機制替代了
  2. 而實現慣性連續的方式其實非常簡單,不需要增加新的機制,直接通過滑動的協同消費機制,在ns child進行慣性滑動時,把滑動傳遞出來,就可以了
  3. 所以第一版 NestedScrolling 機制本身是沒有問題的,有問題的是那些系統控制元件使用這個機制的方式不對
  4. 所以修復這個Bug也很簡單,只是比較繁瑣:修改所有作為ns child角色使用了巢狀滑動機制的系統控制元件,慣性相關的 API 和處理邏輯都可以保留,只要在computeScroll()中把滑動用dispatchNestedPreScroll()dispatchNestedScroll()方法分發給 ns parent,再更改一下解除與ns parent繫結的時機,放在 fling 結束之後
  5. 你自己的ns child View 可以直接改,但系統提供的NestedScrollViewRecyclerView等控制元件,你就只能提個 issue 等官方修復了,不過也可以拷貝一份出來自己改

3.3. 第二個版本,2017年9月

Google表示才不想搭理這些人,給你用就不錯了哪來那麼多事兒?我還要忙著搞AI呢 直到兩年多後的2017年9月,Revision 26.1.0才悄咪咪 更新日誌裡沒有提,但是文件的新增記錄裡能看到,後來發現作者自己倒是寫了篇部落格說這事,說是Revision 26.0.0-beta2時加的,跟文件裡寫的不一致,不過這不重要) 更新了一版NestedScrollingChild2NestedScrollingParent2,並且處理了第一版中系統控制元件的Bug,這便是第二個版本的 NestedScrolling 機制了

來看看第二版是怎麼處理第一版 Bug 的,大牛的救火思路果然比一般人要健壯。

首先看介面是怎麼改的:

  1. ns childcomputeScroll中分發滑動給ns parent沒有問題(這是關鍵),但是我要區分開是使用者手指移動觸發的滑動還是由慣性觸發的滑動(這是錦上添花)
  2. 於是第二版中給所有NestedScrollingChild中滑動相關的 (確切地說是除了「fling相關、滑動開關」外的) 5個方法、所有NestedScrollingParent中滑動相關的 (確切地說是除了「fling相關、獲取滑動軸」外的) 5個方法,都增加了一個引數typetype有兩個取值代表上述的兩種滑動型別:TYPE_TOUCHTYPE_NON_TOUCH
  3. 所以第二版的兩個介面沒有增刪任何方法,只是給10個方法加了個type引數,並且對舊的介面做了個相容,讓它們的typeTYPE_TOUCH

改完了介面當然還要改程式碼了,Helper 類首先要改

  1. 第一版的 NestedScrollingChildHelper 裡邊本來持有了一個ns parentmNestedScrollingParentTouch,作為繫結關係,第二版 又再加了一個ns parentmNestedScrollingParentNonTouch,為什麼是兩個而不是公用一個,大概是避免對兩類滑動的生命週期有過於嚴格的要求,比如在 NestedScrollView 的實現裡,就是先開啟TYPE_NON_TOUCH型別的滑動,然後關閉了 TYPE_TOUCH 型別的滑動,如果公用一個 ns parent 域,就做不到這樣了
  2. NestedScrollingChildHelper 裡邊主要就做了這一點額外的改動,其他的改動都是增加引數後的常規變換,NestedScrollingParentHelper 裡就更沒有特別的變化了

前面在分析第一版 Bug 的時候說過「第一版 NestedScrolling 機制本身是沒有問題的,有問題的是那些系統控制元件使用這個機制的方式不對」,所以這次改動最大的還是那些使用了巢狀滑動機制的系統控制元件了,我們就以 NestedScrollView 為例來具體看看系統是怎麼修復 Bug、建議大家現在應該怎麼建立 ns child 角色的。 相同的部分不說了,在呼叫相關方法的時候要傳入 type 也不細說了,主要的變化基本出現在預期的位置:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        fling(velocityY); // 華點
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() > 0) {
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
        
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0); 
        
        mLastScrollerY = getScrollY();
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        final int x = mScroller.getCurrX();
        final int y = mScroller.getCurrY();
    
        int dy = y - mLastScrollerY;
    
        // Dispatch up to parent
        if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
            dy -= mScrollConsumed[1];
        }
    
        if (dy != 0) {
            final int range = getScrollRange();
            final int oldScrollY = getScrollY();
    
            overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);
    
            final int scrolledDeltaY = getScrollY() - oldScrollY;
            final int unconsumedY = dy - scrolledDeltaY;
    
            if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null, ViewCompat.TYPE_NON_TOUCH)) {
                if (canOverscroll()) showOverScrollEdgeEffect();
            }
        }
    
        ViewCompat.postInvalidateOnAnimation(this);
    } else {
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
}
複製程式碼

computeScroll()方法的程式碼貼得比較多,因為它不僅是這次Bug修復的主要部分,它還是下一次Bug修復要改動的部分。 不過其實整個邏輯還是很簡單的,符合預期,簡單說明一下:

  1. UP時候做的事情沒有變,還是在這解除了與ns parent的繫結,但是註明了型別是TYPE_TOUCH
  2. flingWithNestedDispatch()這個方法先不說
  3. fling()方法中,呼叫startNestedScroll()開啟了新一輪繫結,不過這時的型別變成了TYPE_NON_TOUCH
  4. 最多的改動是在computeScroll()方法中,但邏輯很清晰:對於每個dy,都會經過「parent -> child -> parent -> child」這個消費流程,從而實現了慣性連續,解決了 Bug

最後的效果是這樣:

【透鏡系列】看穿 > NestedScrolling 機制 >

另外,從這版開始,View和 ViewGroup 裡的 NestedScrolling 機制就沒有更新過,一直維持著第一個版本的樣子。

3.3.1. 第二個版本的Bug

看上去第二個版本改得很漂亮對吧,但這次改動其實又引入了兩個問題,至少有一個算是Bug,另一個可以說只是互動不夠好,不過這個互動不夠好的問題引入的原因卻非常令人迷惑。

先說第一個問題:「二倍速」回到小結

【透鏡系列】看穿 > NestedScrolling 機制 >

  1. 我只知道它正好出現在了NestedScrollView中,RecyclerView等類沒有這個問題,我極度懷疑它的引入是因為手滑
  2. 它的現象是這樣:當外部 View 不在頂部、內部 View 在頂部時,往下滑動內部 View 然後快速抬起(製造 fling )
    1. 預期效果應該是:外部 View 往下進行慣性滑動
    2. 實際上也大概是這樣,但有一點點區別:外部 View 往下滑動的速度會比你預想中要快,大概是兩倍的速度(反方向也是一樣),如下圖
  3. 為什麼會這樣呢?
    1. 你如果把第二版巢狀滑動機制更新的NestedScrollView跟之前的對比,你會很容易發現flingWithNestedDispatch()中(在我貼出來的程式碼裡),fling(velocityY)前的if (canFling)離奇消失了
    2. 但消失不代表是手滑,可能是邏輯使然,於是梳理了一下邏輯,這個 if 判斷在新的機制中需要去掉嗎?額,並不需要。沒有了 if 會讓外部 View 同時進行兩個 fling,實際體驗也確實是這樣
  4. 所以解決這個問題很簡單,直接把 if 判斷補上就好了
  5. 不過這個問題在體驗上不算明顯,不過也不難發現,只是使用者可能不知道這是個 Bug 還是 Feature(233

然後是第二個問題:「空氣馬達」回到小結

【透鏡系列】看穿 > NestedScrolling 機制 >

  1. 這個問題肯定算 Bug 了,所有的巢狀滑動控制元件都存在,而且體驗非常明顯
  2. 這個問題就比較硬核了,真的是 NestedScrolling 機制的問題,確切地說應該叫缺陷,在第一版中就存在,只是第一版中系統控制元件的不當的機制使用方式正好不會觸發這個問題,但是在第二版後,各個控制元件改用了新的使用方式,這個問題終於暴露出來了
  3. 它的現象是這樣:當外部 View 在頂部、內部 View 也在頂部時,往下滑動內部 View 然後快速抬起(製造 fling ),(目前什麼都不會發生,因為都滑到頂了,關鍵是下一步) 你馬上滑外部 View
    1. 預期應該是:外部 View 往上滾動
    2. 但實際上你會發現:你滑不動它,或是滑上去一點,馬上又下來了,像是有一臺無形的馬達在跟你的手指較勁(反方向也是一樣),如上圖
  4. 為什麼會這樣呢?
    1. 其實我開始也不得要領,只好打日誌去看到底誰是那個馬達,除錯了好一會*(當時還鬧了個笑話有空再寫)*才發現原來馬達就是內部 View
    2. 原因解釋起來也是非常簡單的:
      1. 先回頭看方法flingWithNestedDispatch()中的這段程式碼:其中的dispatchNestedPreFling()大部分時候會返回false,於是幾乎所有的情況下,內部 View 都會通過fling()方法啟動自己mScroller這個小馬達
      2. 然後在小馬達啟動後,到computeScroll()方法中,你會看到,(如果你不直接觸控內部View) 除非等到馬達自己停止,否則沒有外力能讓它停下,於是它會一直向外輸出dispatchNestedPreScroll()dispatchNestedScroll()
      3. 所以在上面的現象中,即使內外的 View 都在頂部,都無法滑動,內部 View 的小馬達還在突突突地工作,只要你把外部 View 滑到不在頂部的位置,它就又會把它給滑下來
      4. 所以其實不需要前面說的「當外部View在頂部、內部View也在頂部時」這種場景(這只是最好復現的場景),當以任何方式開啟了內部 View 的小馬達後,你又不通過直接觸控內部 View 把它關閉時,都能看到這個問題
  5. 那怎麼辦?這個問題的癥結在哪兒?
    1. 首先內部 View 的小馬達是不能廢棄的,沒有它,怎麼突突突地驅動外部 View 呢?
    2. 但也不能任它突突突轉個不停,除了使用者直接觸控內部 View 讓它停止,它還需要有一個停止開關,至少讓使用者觸控外部 View 的時候也能關閉它,更合理的實現還應該讓驅動過程能夠反饋,當出現情況無法驅動(比如內外都滑到頂部)時,停下馬達
  6. 所以現在需要給驅動過程增加反饋
    1. 前文講過,這個機制中ns child是主動的一方,ns parent完全是被動的,ns parent沒法主動通知ns child:啊我被摁住了,啊我撞牆了
    2. ns parent並不是沒辦法告知ns child資訊,通過方法的返回值和引用型別的引數,ns child仍然可以從ns parent中獲取資訊
    3. 所以只要給 NestedScrolling 機制加一組方法,讓ns child詢問ns parent是否能夠滑動,問題應該就解決了:如果ns parent滑不動了,ns child自己也滑不動,那就趕緊關閉馬達吧,節約能源人人有責
  7. 我們想得確實美,但我們又吃不上G家的飯, NestedScrolling 機制不是你寫的,你怎麼給整個機制加個方法?好吧,那隻能看看這個 NestedScrolling 機制有什麼後門能利用了
    1. 一嘗試就發現可能有戲,詢問ns parent是否能夠滑動不是有現成的方法嗎?
    2. dispatchNestedPreScroll()會先讓ns parentns child之前進行滑動,而且滑動的距離被記錄在它的陣列引數consumed中,拿到陣列中的值ns child就能知道ns parent是否在這時滑動了
    3. dispatchNestedScroll()會讓ns parentns child之後進行滑動,它有沒有陣列引數記錄滑動距離,它只有一個返回值記錄是否消費了滑動...不對,這個返回值不是記錄是否消費滑動用的,它表示的是ns parent是否能順利聯絡上,如果能,就返回true,並不關心它是否消費了滑動。在NestedScrollingChild Helper中你也能看到這個邏輯的清晰實現,同時你也會看到在NestedScrollingParent2中它對應的方法是void onNestedScroll(),沒有返回值*(考慮過能不能通過dispatchNestedScroll()int[] offsetInWindow沒被使用的陣列位置來傳遞資訊,結果也因為 parent 中對應的方法不帶這個引數而告終;而且ns parent也無法主動解除自己與ns child的繫結,這條路也不通)*。總之,dispatchNestedScroll()無法讓ns child得知ns parent對事件的消費情況,此路不通
    4. (其實之後通過把dispatchNestedScroll()的消費結果直接放在ns child的 View 中,用這個後門解決了Bug,但這種方式使用的侷限比較大,而且下面要介紹的最新的第三版已經修復了這個問題,我就不多寫了)

3.4. 第三個版本,2018年11月

第二版的 Bug 雖然比第一版的嚴重,但好像沒有太多人知道,可能這種使用場景還是沒有那麼多。 不過時隔一年多,Google 終於是意識到了這個問題,在最近也就是2018年11月5日androidx.core 1.1.0-alpha01更新中,給出了最新的修復——NestedScrollingChild3NestedScrollingParent3,以及一系列系統元件也陸續進行了更新。

這就是第三個版本的 NestedScrolling 機制了,這個版本確實對上面兩個 Bug 進行了處理,但可惜的是,第二個 Bug 並沒有修理乾淨 (為 Google 大佬獻上一首つづく,期待第四版) (在本文快要完成的時候正好看到新一任消防員在18年12月3日發了條 twitter 說已經發布了第三版,結果評論區大家已經在歡樂地期待 NestedScrollingChild42 NestedScrollingChildX NestedScrollingParentXSMax NestedScrollingParentFinalFinalFinal NestedScrollingParent2019 了 )

繼續來看看在這個版本中,大佬是怎麼救火的

照例先看介面,一看介面的改動你可能就笑了,真的是哪裡不通改哪裡

  1. 在介面NestedScrollingChild3中,沒有增加方法,只是給dispatchNestedScroll方法增加了一個引數int[] consumed,並且把它的boolean返回值改成了void,有了能獲取更詳細資訊的途徑,自然就不需要這個boolean
  2. 介面NestedScrollingParent3同樣只是改了一個方法,給onNestedScroll增加了int[] consumed引數(它返回值就是 void,沒變)

下面是NestedScrollingChild3中的對比:

// 2
boolean dispatchNestedScroll(
    int dxConsumed, int dyConsumed,
    int dxUnconsumed, int dyUnconsumed, 
    @Nullable int[] offsetInWindow,
    @NestedScrollType int type
);
    
// 3
void dispatchNestedScroll(
    int dxConsumed, int dyConsumed, 
    int dxUnconsumed, int dyUnconsumed,
    @Nullable int[] offsetInWindow, 
    @NestedScrollType int type,
    @NonNull int[] consumed // 這個
);
複製程式碼

再看下 Helper ,NestedScrollingChildHelper除了適配新的介面基本沒有改動,NestedScrollingParentHelper也只是增強了一點邏輯的嚴謹性(大概是被review了233)

最後看用法,還是通過我們的老朋友NestedScrollView來看,改動部分跟預期基本一致:

@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
            
    final int oldScrollY = getScrollY();
    scrollBy(0, dyUnconsumed);
    final int myConsumed = getScrollY() - oldScrollY;
    
    if (consumed != null) consumed[1] += myConsumed; // 就加了這一句
    
    final int myUnconsumed = dyUnconsumed - myConsumed;
    mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
    
// ---
    
// onTouchEvent 中邏輯沒有變化
private void flingWithNestedDispatch(int velocityY) {
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, true);
        fling(velocityY); // fling 中的邏輯沒有變化
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.isFinished()) return;
    mScroller.computeScrollOffset();
    final int y = mScroller.getCurrY();
    
    int unconsumed = y - mLastScrollerY;
    
    // Nested Scrolling Pre Pass
    mScrollConsumed[1] = 0;
    dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH);
    unconsumed -= mScrollConsumed[1];
    
    final int range = getScrollRange();
    
    if (unconsumed != 0) {
        // Internal Scroll
        final int oldScrollY = getScrollY();
        overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
        final int scrolledByMe = getScrollY() - oldScrollY;
        unconsumed -= scrolledByMe;
    
        // Nested Scrolling Post Pass
        mScrollConsumed[1] = 0;
        dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset, ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
        unconsumed -= mScrollConsumed[1];
    }
    
    // 處理最後還有 unconsumed 的情況
    if (unconsumed != 0) {
        if (canOverscroll()) showOverScrollEdgeEffect();
    
        mScroller.abortAnimation(); // 關停小馬達
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
    
    if (!mScroller.isFinished()) ViewCompat.postInvalidateOnAnimation(this);
}
複製程式碼

修改最多的還是computeScroll(),不過其他地方也有些變化,簡單說明一下:

  1. 因為onNestedScroll()增加了記錄距離消耗的引數,所以ns parent就需要把這個資料記錄上並且繼續傳遞給自己的ns parent
  2. flingWithNestedDispatch()是之前有蜜汁 Bug 的方法,本來我的預期是恢復第一版的寫法,也就是把fling(velocityY)前的if (canFling)加回來,結果這下倒好,連canFling也不判斷了,dispatchNestedFling(0, velocityY, true)直接傳truefling(velocityY)始終呼叫。這意味著什麼呢?需要結合大部分View的寫法來看
    1. 搜尋API 28的程式碼你就會看到:
      1. 對於onNestedPreFling()方法,除了ResolverDrawerLayout會在某些情況下消費fling並返回true,以及CoordinatorLayout會象徵性地問一遍自己孩子們的Behavior,其它的寫法都是直接返回false
      2. 對於onNestedFling(boolean consumed)方法,所有的寫法都是,只要consumedtrue,就什麼都不會做,這種做法也非常自然
    2. 所以當前的現狀是:絕大部分情況下,內部 View 的 fling 小馬達都會啟動,外部 View 都不會消費內部 View 產生的 fling。這就代表著:慣性的協作機制完全被滑動的協作機制取代了。這也是我不推薦給初學者介紹這組沒什麼用的介面的原因
    3. 但當然,即使名存實亡,但如果你真的有特殊需求需要使用到 fling 的傳遞機制,你也是可以用的
  3. 最後來看computeScroll(),它基本把我們在討論怎麼修復第二版中 Bug 時的思路實現了:因為能從dispatchNestedPreScroll()dispatchNestedScroll()得知ns parent消耗了多少這一次分發出去的滑動距離,同時也有自己消耗了多少,兩者一合計,如果還有沒消耗的滑動距離,那肯定無論內外都滑到頭了,於是就該果斷就把小馬達關停

現在的效果是這樣的,能看到第二版中的Bug確實解決了

【透鏡系列】看穿 > NestedScrolling 機制 >

3.4.1. 第三個版本的Bug

那麼為什麼我還說第二個 Bug 沒有解決徹底呢?

  1. 對比程式碼容易看到,第三版中DOWN事件的處理相對第二版沒有變化,它沒有加入觸控外部 View 後關閉內部 View 馬達的機制,更確切地說是沒有加入「觸控外部 View 後阻止對內部 View 傳遞過來的滑動進行消費的機制」
  2. 所以只有外部 View 滑動到盡頭的時候才能關閉馬達,外部 View 沒法給內部 View 反饋自己被摁住了

雖然現象與「空氣馬達」類似,但還是按照慣例給它也起個好聽的新名字,就叫:...「摁不住」回到小結

實際體驗跟分析結果一樣這樣,當通過滑動內部 View 觸發外部 View 滑動時,你無法通過觸控外部 View 把它停下來,外部 View 比較長的時候容易復現,如下圖(換了一個方向)

-w200

不過這個問題只有可以響應觸控的ns parent需要考慮,可以響應觸控的ns parent主要就是NestedScrollView了,所以這個問題主要還是NestedScrollView的問題。而且它也跟機制無關,只是NestedScrollView的用法不對,所以前面說的會有第四版 NestedScrolling 機制可能性也不大,大概只會給NestedScrollView上個普通的更新吧(順手給 Google 大佬遞了瓶 可樂

而這個問題自己改也非常好改,只需要在DOWN事件後能給ns child反饋自己被摁住了就行,可以用反射,或是直接把NestedScrollView挪出來改,關鍵程式碼如下

private boolean mIsBeingTouched = false;

@Override
public boolean onTouchEvent(MotionEvent ev) {
    switch (ev.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            mIsBeingTouched = true;
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            mIsBeingTouched = false;
            break;
    }

    return super.onTouchEvent(ev);
}

private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
    final int oldScrollY = getScrollY();
    if (!mIsBeingTouched) scrollBy(0, dyUnconsumed); // 只改了這一句
    final int myConsumed = getScrollY() - oldScrollY;

    if (consumed != null) {
        consumed[1] += myConsumed;
    }
    final int myUnconsumed = dyUnconsumed - myConsumed;

    childHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
複製程式碼

我把用反射改好的放在這裡了,你也可以直接使用 改完之後效果如下:

【透鏡系列】看穿 > NestedScrolling 機制 >

3.5. 小結

歷史終於講完了,小結一下回去看詳細歷史

  1. 2014年9月,Google 在Android 5.0( API 21)中的 View 和 ViewGroup 中加入了第一個版本的 NestedScrolling 機制,此時能夠通過啟用巢狀滑動,讓巢狀的ScrollView不出現互動問題,但這個機制只有 API 21 以上才能使用
  2. 2015年4月,Google 重構了第一個版本的 NestedScrolling 機制,邏輯沒有變化,但是把它從 View 和 ViewGroup 中剝離,得到了兩個介面(NestedScrollingChildNestedScrollingParent)和兩個 Helper (NestedScrollingChildHelperNestedScrollingParentHelper),並且用這套新的機制重寫了一個預設啟用巢狀滑動的NestedScrollView,並把它們都放入了Revision 22.1.0v4 support library,讓低版本的系統也能使用巢狀滑動機制,不過此時的第一版機制有「慣性不連續」的 Bug
  3. 2017年9月,Google 在Revision 26.1.0v4 support library中釋出了第二個版本的 NestedScrolling 機制,增加了介面NestedScrollingChild2NestedScrollingParent2,主要是給原本滑動相關的方法增加了一個引數type,表示了兩種滑動型別TYPE_TOUCHTYPE_NON_TOUCH。並且使用新的機制重寫了巢狀滑動相關的控制元件。這次更新解決了第一個版本中「慣性不連續」的Bug,但也引入了新的Bug:「二倍速」(僅NestedScrollView)和「空氣馬達」
  4. 2018年11月,Google 給已經併入AndroidX 家族的 NestedScrolling 機制更新了第三個版本,具體版本是androidx.core 1.1.0-alpha01,增加了介面NestedScrollingChild3NestedScrollingParent3,改動只是給原來的dispatchNestedScroll()onNestedScroll()增加了int[] consumed引數。並且後續把巢狀滑動相關的控制元件用新機制進行了重寫。這次更新解決了第二個版本中 NestedScrollView的「二倍速」Bug,同時期望解決「空氣馬達」Bug,但是沒有解決徹底,還遺留了「摁不住」Bug

所以前面的問題大家應該都有了答案

  1. 使用介面和 Helper 是為了相容低版本和容易升級,並不是 NestedScrolling 機制用起來最方便的樣子。所以為了便於理解,我就直接說呼叫 View 和 ViewGroup 的方法,但真正用的時候你最好還是在 Helper 的幫助下實現它最新的介面,然後再呼叫你實現的這些方法,因為 View 和 ViewGroup 的方法對 API 的版本要求高,自己的版本又很低。這點使用上的變化比較簡單,因為方法名跟 View 和 ViewGroup 中的都一樣,Helper 的使用也很直接,就不舉例子了。
  2. 常用的方法也就是這9個了,剩下的8個不用急著去了解,其中 fling 相關方法有點涼涼的味道。然後第二版機制和第三版機制並沒有增加新的方法,機制的總體設計沒有大的變化。
  3. 第二版和第三版都是在修 Bug ,恩,還沒修完。

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

4. 實踐

第二節中其實已經講過了實踐,並且提供了實現 ns child 的模板。 這裡我準備用剛發現的一個更有實際意義的例子來講一下 ns parent 的實現,以及系統庫中 ns child 的幾個細節。

4.1. 選題:懸停佈局

這個例子是「懸停佈局」 你叫它粘性佈局、懸浮佈局、摺疊佈局都行,總之它理想的效果應該是這樣:

【透鏡系列】看穿 > NestedScrolling 機制 >

用文字描述是這樣:

  1. 頁面內容分為 Header、懸停區(一般會是 TabLayout)和內容區,其中內容區可以左右滑動,有多個 Tab 頁,而且每個 Tab 頁是允許上下滑動的
  2. 使用者向上滑動時,先摺疊 Header,當 Header 全部摺疊收起後,懸停區懸停不動,內容區向上滑動
  3. 使用者向下滑動時,先把內容區向下滑動,然後展開 Header,懸停區順勢下移
  4. 其中內容區的滑動和 Header 的收起展開在使用者連續滑動時應該表現為連續的,甚至在使用者滑動中快速抬起時,滑動的慣性也需要在兩個動作間保持連續

在當前這個時間點(2019.1.13),這個例子還有不少實際意義,因為它雖然是比較常見的一個互動效果,但現在市場上的主流APP,居然是這樣的...(餓了麼v8.9.3)

【透鏡系列】看穿 > NestedScrolling 機制 >
這樣的...(知乎v5.32.2)
【透鏡系列】看穿 > NestedScrolling 機制 >
這樣的...(騰訊課堂v3.24.0.5)
【透鏡系列】看穿 > NestedScrolling 機制 >
這樣的...(嗶哩嗶哩v5.36.0)
【透鏡系列】看穿 > NestedScrolling 機制 >
【透鏡系列】看穿 > NestedScrolling 機制 >

先不管它們是不是用 Native 實現的,只看實現的效果

  1. 其中嗶哩嗶哩的視訊詳情頁和美團(沒有貼圖)算是做得最好的,滑動連續慣性也連續,但也存在一個小瑕疵:在 Header 部分上下滑動時你可以同時進行左右滑動,容易誤操作
  2. 而騰訊課堂的問題是最普遍的:慣性不連續
  3. 最奇葩是餓了麼的店鋪首頁和知乎的 Live 詳情頁,都是創收的頁面啊,居然能自帶鬼畜,好吧,也是心大

其他還有一些千奇百怪的 Bug 就不舉例了。 所以,就讓我們來看看,這個功能實現起來是不是真有那麼難。

4.2. 需求分析

如果內容區只有一個 Tab 頁,一種簡單直接的實現思路是:頁面整個就是一個滑動控制元件,懸停區域會在滑動過程中不斷調整自己的位置,實現懸停的效果。 它的實現非常簡單,效果也完全符合要求,不舉例了,可以自己試試。

但這裡的需求是有多個 Tab 頁,它用一整個滑動控制元件的思路是無法實現的,需要用多個滑動控制元件配合實現

  1. 先看看有哪些滑動控制元件:每個 Tab 頁內肯定是獨立的滑動控制元件,要實現 Header 的展開收起,可以把整個容器作為一個滑動控制元件
  2. 這就變成了一個外部滑動控制元件和一組內部滑動控制元件進行配合,看上去有點複雜,但實際上在一次使用者滑動過程中,只有一個外部滑動控制元件和一個內部滑動控制元件進行配合
  3. 配合過程是這樣的(可以回頭看下前面的理想效果動態圖):
    1. 使用者上滑,外部滑動控制元件先消費事件進行上滑,直到滑動到 Header 的底部,外部滑動控制元件滑動結束,把滑動事件交給內部滑動控制元件,內部滑動控制元件繼續滑動
    2. 使用者下滑,內部滑動控制元件先消費事件進行下滑,直到滑動到內部控制元件的頂部,內部滑動控制元件滑動結束,把滑動事件交給外部滑動控制元件,外部滑動控制元件繼續滑動
    3. 當使用者滑動過程中快速抬起進行慣性滑動的時候,也需要遵循上面的配合規律

在瞭解 NestedScrolling 機制之前,你可能覺得這個需求不太對勁,確實,從大的角度看,使用者的一次觸控操作,卻讓多個 View 先後對其進行消費,它違背了事件分發的原則,也超出了 Android 觸控事件處理框架提供的功能:父 View 沒用完的事件子 View 繼續用,子 View 沒用完的事件父 View 繼續用

但具體到這個需求中

  1. 首先,兩個滑動控制元件配合消費事件的期望效果是,與內容區只有一個 Tab 頁一樣,讓使用者感知上認為自己在滑動一整個控制元件,只是其中某個部分會懸停,它並沒有違背使用者的直覺。所以,經過精心設計,多個View 消費同一個事件流也是可以符合使用者直覺的。在這個領域表現最突出的就是CoordinatorLayout了,它就是用來幫助開發者去實現他們精心設計的多個 View 消費同一個事件流的效果的
  2. 然後,由於滑動反饋的簡單性,讓多個滑動控制元件的滑動進行配合也是能夠做到的。你可以自己實現,也可以藉助我們已經熟悉的NestedScrolling機制實現。另外CoordinatorLayout讓多個滑動控制元件配合對同一個事件流進行消費也是利用NestedScrolling機制

OK,既然需求提得沒問題,而且我們也能實現,那下面就來看看具體要怎麼實現。

可能有同學馬上就舉手了:我知道我知道,用CoordinatorLayout! 對,當前這個效果最常見的實現方式就是使用基於CoordinatorLayoutAppBarLayout全家桶,這是它的自帶效果,通過簡單配置就能實現,而且還附送更多其他特效,非常酷炫,前面看到的效果比較好的嗶哩嗶哩視訊詳情頁就是用它實現的。 而AppBarLayout實現這個功能的方式其實是也使用了CoordinatorLayout提供的NestedScrolling機制(雖然實現的具體方法跟上面的分析有些區別,但並不重要,感興趣的同學可以看AppBarLayoutBehavior),如果你嫌棄AppBarLayout全家桶太重了,只想單獨實現懸停功能,如前文所述,你也可以直接使用NestedScrolling機制去實現。

這裡就直接使用NestedScrolling機制來實現出一個類似嗶哩嗶哩這樣正常一些的懸停佈局。

4.3. 需求實現

NestedScrolling機制一想,你會發現實現起來非常簡單,上面的分析過程在機制中直接就有對應的介面,我們只要實現一個符合要求的 ns parent 就好了,NestedScrolling機制會自動管理 ns parentns child 的繫結和 scroll 的傳遞,即使 ns childns parent 相隔好幾層 View。

我把要實現的 ns parent 叫做 SuspendedLayout ,其中的關鍵程式碼如下,它剩下的程式碼以及佈局和頁面程式碼就不寫出來了,可以在這裡檢視(簡單把第一個 child view 作為 Header,第二個 child view 會自然懸停)。

override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
    if (dyUnconsumed < 0) scrollDown(dyUnconsumed, consumed)
}

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy > 0) scrollUp(dy, consumed)
}

/*-------------------------------------------------*/

private fun scrollDown(dyUnconsumed: Int, consumed: IntArray?) {
    val oldScrollY = scrollY
    scrollBy(0, dyUnconsumed)
    val myConsumed = scrollY - oldScrollY

    if (consumed != null) {
        consumed[1] += myConsumed
    }
}

private fun scrollUp(dy: Int, consumed: IntArray) {
    val oldScrollY = scrollY
    scrollBy(0, dy)
    consumed[1] = scrollY - oldScrollY
}

override fun scrollTo(x: Int, y: Int) {
    val validY = MathUtils.clamp(y, 0, headerHeight)
    super.scrollTo(x, validY)
}
複製程式碼

這麼快就實現了,效果非常完美,與嗶哩嗶哩幾乎一樣:

【透鏡系列】看穿 > NestedScrolling 機制 >

4.4. 優化誤操作問題

但效果一樣好也一樣壞,嗶哩嗶哩的那個容易誤操作的問題這裡也有。 先看看為什麼會出現這樣的問題?

  1. 從問題表現上很容易找到線索,肯定是在上滑過程中被 ViewPager 攔截了事件,也就是 ns child 沒有及時「申請外部不攔截事件流」,於是到 NestScrollViewRecyclerView 中檢視,問題其實就出在前面描述的ns childonTouchEvent() 中的邏輯
  2. 因為 ns child 會在判斷出使用者在滑動後「申請外部不攔截事件流」,但 onTouchEvent() 中又在判斷出使用者在滑動前就把滑動用 dispatchNestedPreScroll() 方法傳遞給了 ns parent,於是你就會看到,明明已經識別出我在上下滑動ns child了,而且已經滑了一段距離,居然會忽然切換成滑動 ViewPager

所以這個問題要怎麼修復呢?

  1. 直接修改原始碼肯定是解決辦法
    1. 我嘗試了把NestScrollView程式碼拷貝出來,並把其中的 dispatchNestedPreScroll() 方法放在判斷出滑動之後進行呼叫,確實解決了問題
  2. 但能不能不去拷貝原始碼呢?
    1. 也是可以的,只要能及時呼叫parent.requestDisallowInterceptTouchEvent(true)即可,完整程式碼見此,其中關鍵程式碼如下:
private int downScreenOffset = 0;
private int[] offsetInWindow = new int[2];

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
        downScreenOffset = getOffsetY();
    }

    if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) {
        final int activePointerIndex = ev.findPointerIndex(getInt("mActivePointerId"));
        if (activePointerIndex != -1) {
            final int y = (int) ev.getY(activePointerIndex);
            int mLastMotionY = getInt("mLastMotionY");
            int deltaY = mLastMotionY - y - (getOffsetY() - downScreenOffset);

            if (!getBoolean("mIsBeingDragged") && Math.abs(deltaY) > getInt("mTouchSlop")) {
                final ViewParent parent = getParent();
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
                setBoolean("mIsBeingDragged", true);
            }
        }
    }

    return super.onTouchEvent(ev);
}

private int getOffsetY() {
    getLocationInWindow(offsetInWindow);
    return offsetInWindow[1];
}
複製程式碼

這裡有個細節值得一提:在計算deltaY時不只是用mLastMotionY - y,還減去了(getOffsetY() - downScreenOffset),這裡的offsetInWindow其實也出現在 NestedScrolling 機制裡的dispatchNestedScroll()等介面中

  1. offsetInWindow的作用非常關鍵,因為當 ns child 驅動 ns parent 滑動時,ns child 其實也在移動,此時ns child中獲取到的手指觸發的motion eventxy值是相對ns child的,所以此時如果直接使用y值,你會發現y值幾乎沒有變化,這樣算到的deltaY也會沒有變化,所以需要再獲取ns child相對視窗的偏移,把它算入deltaY,才能得到你真正需要的deltaY
  2. ViewPager為什麼會在豎直滑動那麼遠之後還能對橫滑進行攔截,也是這個原因,它獲取到的deltaY其實很小

改完之後的效果如下,能看到解決了問題:

【透鏡系列】看穿 > NestedScrolling 機制 >

RecyclerView等其他的ns child如果需要的話,也可以做類似的改動(不過這裡的反射程式碼對效能有所影響,建議實現上做一些優化)

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

5. 總結

如果你沒有跳過地看到這裡,關於 NestedScrolling 機制,我相信現在無論是使用、還是原理、甚至八卦歷史,你都瞭解得一清二楚了,否則我只能懷疑你的我的語文老師表達水平了。

而關於程式碼的設計,你大概也能學到一點,Google 工程師三入火場英勇救火的身影應該給你留下了深刻的印象。

最後關於使用多說兩句:

  1. 如果你需要目前最好的巢狀滑動體驗,不管是直接用系統 View 還是自定義 View ,直接用最新的 AndroidX 吧,並且自定義的時候注意使用3系列
  2. 如果你的專案暫時不方便切換 AndroidX,那麼就升級到最新的 v4 吧,注意自定義的時候用2系列
  3. 如果你的專案追求極致體驗,而且正好用到了巢狀的NestedScrollView,認為第三版的 Bug 也會影響到你寶貴而敏感的使用者,那不如試試 implementation 我的專案 :D

最後的最後,G 家的消防員都有顧不過來的時候,更何況是本菜雞,本文內容肯定會有疏漏和不當之處,歡迎大家提 issue 啦~

(覺得寫得好的話,不妨點個贊再走呀~ 給作者一點繼續寫下去的動力)

相關文章