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

(轉載請註明做者:RubiTree,地址:blog.rubitree.comjava

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

1. 嵌套同向滑動

1.1. 嵌套同向滑動的問題

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

-w350

這種狀況若是使用通常的處理方式,會出現交互問題,好比使用兩個ScrollView進行佈局,你會發現,觸摸着內部的ScrollView進行滑動,它是滑不動的 (不考慮後來 Google 給它加的NestedScroll開關)github

1.2. 分析問題緣由

(舒適提示:本文涉及事件分發的內容比較多,建議對事件分發不太熟悉的同窗先閱讀另外一篇透鏡《看穿 > 觸摸事件分發》數組

若是你熟悉 Android 的觸摸事件分發機制,那麼緣由很好理解:兩個ScrollView嵌套時,滑動距離終於達到滑動手勢斷定閾值(mTouchSlop)的這個MOVE事件,會先通過父 View 的onInterceptTouchEvent()方法,父 View 因而直接把事件攔截,子 View 的onTouchEvent()方法裏雖然也會在斷定滑動距離足夠後調用requestDisallowInterceptTouchEvent(true),但始終要晚一步。app

而這個效果顯然是不符合用戶直覺的 那用戶但願看到什麼效果呢?框架

  1. 大部分時候,用戶但願看到:當手指觸摸內部ScrollView進行滑動時,能先滑動內部的ScrollView,只有當內部的ScrollView滑動到盡頭時,才滑動外部的ScrollView

這看上去很是天然,也跟觸摸事件的處理方式一致,但相比觸摸事件的處理,要在滑動時實現一樣的效果卻會困難不少ide

  1. 由於滑動動做不能馬上識別出來,它的處理自己就須要經過事件攔截機制進行,而事件攔截機制實質上跟《看穿 > 觸摸事件分發》中第一次試造的輪子同樣,只是單向的,並且方向從外到內,因此沒法作到:先讓內部攔截滑動,內部不攔截滑動時,再在讓外部攔截滑動

那能不能把事件攔截機制變成雙向的呢?不是不行,但這顯然違背了攔截機制的初衷,並且它很快會發展成無限遞歸的:雙向的事件攔截機制自己是否也須要一個攔截機制呢?因而有了攔截的攔截,而後再有攔截的攔截的攔截... 佈局

-w150

1.3. 嘗試解決問題

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

但實際上這樣作並很差,由於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
    }
}    
複製代碼

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

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. 這段代碼省略了多點觸控狀況的判斷

運行效果以下:

這樣就完成了對嵌套滑動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)
    }
}
複製代碼

效果以下:

而慣性滑動的實現就會相對複雜一點,得對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實現的不是完美的嵌套滑動,緣由見下一節)

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:「慣性不連續」回到小結

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

簡單說就是:你在滑動內部 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

最後的效果是這樣:

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

3.3.1. 第二個版本的Bug

看上去第二個版本改得很漂亮對吧,但此次改動其實又引入了兩個問題,至少有一個算是Bug,另外一個能夠說只是交互不夠好,不過這個交互不夠好的問題引入的緣由卻很是使人迷惑。

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

  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

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

  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確實解決了

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);
}
複製代碼

我把用反射改好的放在這裏了,你也能夠直接使用 改完以後效果以下:

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. 選題:懸停佈局

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

用文字描述是這樣:

  1. 頁面內容分爲 Header、懸停區(通常會是 TabLayout)和內容區,其中內容區能夠左右滑動,有多個 Tab 頁,並且每一個 Tab 頁是容許上下滑動的
  2. 用戶向上滑動時,先摺疊 Header,當 Header 所有摺疊收起後,懸停區懸停不動,內容區向上滑動
  3. 用戶向下滑動時,先把內容區向下滑動,而後展開 Header,懸停區順勢下移
  4. 其中內容區的滑動和 Header 的收起展開在用戶連續滑動時應該表現爲連續的,甚至在用戶滑動中快速擡起時,滑動的慣性也須要在兩個動做間保持連續

在當前這個時間點(2019.1.13),這個例子還有很多實際意義,由於它雖然是比較常見的一個交互效果,但如今市場上的主流APP,竟然是這樣的...(餓了麼v8.9.3)

這樣的...(知乎v5.32.2)
這樣的...(騰訊課堂v3.24.0.5)
這樣的...(嗶哩嗶哩v5.36.0)

先無論它們是否是用 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)
}
複製代碼

這麼快就實現了,效果很是完美,與嗶哩嗶哩幾乎同樣:

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其實很小

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

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 啦~

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

相關文章
相關標籤/搜索