【透鏡系列】看穿 > 觸摸事件分發 >

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

引子

事件分發,我想大部分人都能說幾句,哦,三大方法,哦,那段經典僞代碼,哦,責任鏈... 但若是要讓你完完整整捋一遍,你可能就開始支支吾吾了,只能想到啥說啥git

這塊的東西確實麻煩,說出來不怕嚇到你,事件流到底怎麼流與這些因素都有關係:是什麼事件類型(DOWN/MOVE/UP/CANCEL)、在哪一個視圖層次(Activity/ViewGroup/View)、在哪一個回調方法(dispatch()/onIntercept()/onTouch())、回調方法給不一樣的返回值(true/false/super.xxx),甚至對當前事件的不一樣處理還會對同一事件流中接下來的事件形成不一樣影響github

好比我能夠問:重寫某個ViewGroup裏的dispatchTouchEvent方法,對MOVE事件返回false,整個事件分發過程會是什麼樣的?微信

因而就有人對這些狀況分門別類進行總結,獲得了不少規律,也畫出了紛繁複雜的事件分發流程圖:app

甚至還有相似題圖那樣的動態流程圖 (是的,吸引你進來的題圖竟然是反面教材,我也很心疼啊,畫了我半個下午,結果並無太大的幫助)框架

這些規律和流程圖確實是對的,並且某種意義上也是很是清晰的,能幫助你在調試 Bug 的時候找到一點方向。 你或許能夠奮發圖強,把這些流程圖和規律背下來,也能在須要的時候一通嘰裏呱啦背完你們大眼瞪小眼。 但它們並不能讓你真正理解事件分發是什麼樣子,你可能某一次花費了大量的時間去看懂它們,可是「每次都能看明白!過一段時間又忘了!」 (某段有表明性的評論原話)ide

但講道理,分發個觸摸事件爲何會這麼複雜呢?須要這麼複雜嗎?圖啥呢?佈局

因而,讓咱們回到起點,看看分發觸摸事件究竟是爲了解決一個什麼樣的問題,有沒有更簡單的分發辦法?而後看看當需求增長的時候,要怎麼調整這個簡單的分發策略? 看到最後你就會發現,原來一切是那麼地天然。post

因此,不用死記硬背,也不用急着去懟完整的事件分發流程,那麼多複雜的邏輯和狀況其實都是圍繞着最根本的問題發展出來的,是隨着需求的增長一步步變得複雜的,理解了演化過程,你天然會對其演化的結果瞭然於胸,想忘都忘不掉。測試

從根本問題出發,一切就會變得天然而然。

艾維巴蒂,黑喂狗! 下面我將從最簡單的需求開始思考方案、編寫代碼,而後一步步增長需求、調整方案、繼續編寫代碼,爭取造出一個麻雀雖小五臟俱全的事件分發框架。

1. 五造輪子

1.1. 一造:直接傳給目標View

咱們先實現一個最簡單的需求:Activity 中有一堆層層嵌套的 View,有且只有最裏邊那個 View 會消費事件

(黃色高亮 View 表明能夠消費事件,藍色 View 表明不消費事件)

-w350

思考方案:

  1. 首先事件從哪兒來,確定得從父親那來,由於子View被包裹在裏面,沒有直接與外界通訊的辦法,而實際中Activity鏈接着根ViewDecorView,它是通往外界的橋樑,能接收到屏幕硬件發送過來的觸摸事件
  2. 因此事件是從Activity開始,通過一層一層 ViewGroup ,傳到最裏邊的 View
  3. 這時只須要一個從外向裏傳遞事件的passEvent(ev)方法,父親一層層往裏調,能把事件傳遞過去,就完成了需求

示意圖

-w400

麻雀代碼:

(本文代碼使用Kotlin編寫,核心代碼也提供了Java版本

open class MView {
    open fun passEvent(ev: MotionEvent) {
        // do sth
    }
}
    
class MViewGroup(private val child: MView) : MView() {
    override fun passEvent(ev: MotionEvent) {
        child.passEvent(ev)
    }
}
複製代碼
  1. 暫時把Activity當成MViewGroup處理也沒有問題
  2. 爲何是MViewGroup繼承MView而不是反過來,由於 MView 是不須要 child 字段的

1.2. 二造:從裏向外傳給目標View

而後咱們增長一條需求,讓狀況複雜一點:Activity中有一堆層層嵌套的View,有好幾個疊着的View能處理事件

-w350

同時須要增長一條設計原則:用戶的一次操做,只能被一個View真正處理(消費)

  1. 要求這條原則是爲了讓操做的反饋符合用戶直覺
  2. 很容易理解,正常狀況下人只會想一次就作一件事
    1. 好比一個列表條目,列表能夠點擊進入詳情,列表上還有個編輯按鈕,點擊能夠編輯條目
      1. 這是一個上下兩個View都能點擊的場景,但用戶點一個地方,確定只想去作一件事,要麼進入詳情,要麼是編輯條目,若是你點編輯結果跳了兩個頁面,那確定是不合適的
    2. 再好比在一個可點擊Item組成的列表裏(好比微信的消息界面),Item能夠點擊進入某個聊天,列表還能滑動上下查看
      1. 若是你讓Item和列表都處理事件,那在你滑動的時候,你可能得跳一堆你不想去的聊天頁面

若是使用第一次試造的框架,要遵照這條原則,就須要在每個能夠處理事件的View層級,判斷出本身要處理事件後,不繼續調用childpassEvent()方法了,保證只有本身處理了事件。 但若是真這樣實現了,在大部分場景下會顯得怪怪的,由於處理事件的順序不對:

  1. 好比仍是上面的列表,當用戶點擊按鈕想編輯條目的時候,點擊事件先傳到條目,若是你在條目中判斷須要事件,而後把事件消費了不傳給子View,用戶就永遠點不開編輯條目了
  2. 並且換個角度看更加明顯,用戶確定但願點哪,哪兒最靠上、離手指最近的東西被觸發

因此實現新增需求的一個關鍵是:找到那個適合處理事件的View,而咱們經過對業務場景進行分析,獲得答案是:那個最裏面的View適合處理事件

這就不能是等parent不處理事件了才把事件傳給child,應該反過來,你須要事件的處理順序是從裏向外:裏邊的child不要事件了,才調用parentpassEvent()方法把事件傳出來。 因而得加一條向外的通道,只能在這條向外的通道上處理事件,前面向裏的通道什麼都不幹,只管把事件往裏傳。 因此這時你有了兩條通道,改個名字吧,向裏傳遞事件是passIn()方法,向外傳遞並處理事件是passOut()方法。

示意圖

-w400

麻雀代碼:

open class MView {
    var parent: MView? = null

    open fun passIn(ev: MotionEvent) {
        passOut(ev)
    }

    open fun passOut(ev: MotionEvent) {
        parent?.passOut(ev)
    }
}

class MViewGroup(private val child: MView) : MView() {
    init {
        child.parent = this // 示意寫法
    }

    override fun passIn(ev: MotionEvent) {
        child.passIn(ev)
    }
}
複製代碼

這段代碼沒有問題,很是簡單,可是它對需求意圖的表達不夠清晰,增長了框架的使用難度

  1. 如前所述,咱們但願passIn()的時候只傳遞事件,但願在passOut()的時候每一個View決定是否要處理事件,並進行處理,並且在處理事件後,再也不調用parentpassOut()方法把事件傳出來
  2. 你會發現,這其中包含了兩類職責:
    1. 一類是事件傳遞控制邏輯,另外一類是事件處理鉤子
    2. 其中事件傳遞控制邏輯基本不會變化,但事件處理的鉤子中可能作任何事情
  3. 咱們須要把不一樣職責的代碼分開,更須要把變化的和不變的分開,減小框架使用者的關注點

因而咱們用一個叫作dispatch()的方法單獨放事件傳遞的控制邏輯,用一個叫作onTouch()的方法做爲事件處理的鉤子,並且鉤子有一個返回值,表示鉤子中是否處理了事件:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = child.dispatch(ev)
        if (!handled) handled = onTouch(ev)

        return handled
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製代碼

這樣寫完,整個行爲其實沒有變化,但你會發現:

  1. 控制邏輯集中在dispatch()中,一目瞭然
  2. onTouch()單純是一個鉤子,框架使用者只須要關心這個鉤子和它的返回值,不用太關心控制流程
  3. 另外,連parent也不須要了

1.3. 三造:區分事件類型

上文的實現看上去已經初具雛形了,但其實連開始提的那條原則都沒實現完,由於原則要求一次操做只能有一個 View 進行處理,而咱們實現的是一個觸摸事件只能有一個View進行處理。 這裏就涉及到一次觸摸操做和一個觸摸事件的區別:

  1. 假設尚未觸摸事件的概念,咱們要怎麼區分一次觸摸操做呢?
    1. 把觸摸操做細分一下,大概有按下動做、擡起動做、與屏幕接觸時的移動和停留動做
    2. 很容易想到,要區分兩次觸摸操做,能夠經過按下和擡起動做進行區分,按下動做開始了一次觸摸操做,擡起動做結束了一次觸摸,按下和擡起中間的移動和停留都屬於這一次觸摸操做,至於移動和停留是否要區分,目前沒有看到區分的必要,能夠都做爲觸摸中來處理
  2. 因而在一次觸摸操做中就有了三種動做的類型:DOWN/UP/ING,其中ING有點不夠專業,改個名字叫MOVE
  3. 而每一個觸摸動做會在軟件系統中產生一個一樣類型的觸摸事件
  4. 因此最後,一次觸摸操做就是由一組從DOWN事件開始、中間是多個MOVE事件、最後結束於UP事件的事件流組成

因而設計原則更確切地說就是:一次觸摸產生的事件流,只能被一個View消費

在上次試造的基礎上把一個事件變成一個組事件流,其實很是簡單:處理DOWN事件時跟前面處理一個事件時同樣,但須要同時記住DOWN事件的消費對象,後續的MOVE/UP事件直接交給它就好了

麻雀代碼:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
        
            handled = child.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) handled = onTouch(ev)
        } else {
            if (isChildNeedEvent) handled = child.dispatch(ev)
            if (!handled) handled = onTouch(ev)
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
            
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製代碼

代碼好像增長了不少,其實只多作了兩件事:

  1. 增長了一個isChildNeedEvent狀態,對是子View是否處理了DOWN事件進行記錄,並在其餘觸摸事件時使用這個狀態
  2. 在收到DOWN事件的最開始和收到UP事件的最後,重置狀態

此時框架使用者仍是隻須要關心onTouch()鉤子,在須要處理事件時進行處理並返回true,其餘事情框架都作好了。

1.4. 四造:增長外部事件攔截

上面的框架已經能完成基本的事件分發工做了,但下面這個需求,你嘗試一下用如今框架能實現嗎? 需求:在可滑動View中有一個可點擊View,須要讓用戶即便按下的位置是可點擊View,再進行滑動時,也能夠滑動外面的的可滑動View

-w380
這個需求其實很是常見,好比全部「條目可點擊的滑動列表」就是這樣的(微信/QQ聊天列表)。

假如使用上面的框架:

  1. 可滑動View會先把事件傳到裏邊的可點擊View
  2. 可點擊View一看來事件了,我又能點擊,那捨我其誰啊
  3. 而後外面的可滑動View就永遠沒法處理事件,也就沒法滑動

因此直接使用如今的模型去實現的「條目可點擊的滑動列表」會永遠滑動不了。

那怎麼辦呢?

  1. 難道要讓裏面的可點擊View去感知一下(層層往上找),本身是否是被一個能消費事件的View包裹?是的話本身就不消費事件了?
    1. 這確定是不行的,先不說子View層層反向遍歷父親是否是個好實現,至少不能外面是能夠滑動的,裏邊View的點擊事件就所有失效
  2. 或者咱們調整dispatch()方法在傳入事件過程當中的人設,讓它不是隻能往裏傳遞事件,而是在本身能消費事件的時候把事件給本身
    1. 這確定也是不行的,跟第一個辦法的主要問題同樣

直接想實現以爲處處是矛盾,找不到突破口,那就從頭開始吧,從什麼樣的觸摸反饋是用戶以爲天然的出發,看看這種符合直覺的反饋方案是否存在,找出來它是什麼,再考慮咱們要怎麼實現:

  1. 當用戶面對一個滑動View裏有一個可點擊View,當他摸在可點擊View上時,他是要作什麼?
  2. 顯然,只有兩個可能性,要麼用戶想點這個可點擊View,要麼用戶想滑動這個可滑動View
  3. 那麼,當用戶剛用手指接觸的時候,也就是DOWN事件剛來的時候,能判斷用戶想幹什麼嗎?很抱歉,不能
  4. 因此,客觀條件下,你就是不可能在DOWN事件傳過來的時候,判斷出用戶到底想作什麼,因而兩個View其實都不能肯定本身是否要消費事件

我*,這不傻*了嗎,還搞什麼GUI啊,你們都用命令行吧 等等,不要着急,GUI仍是得搞的,不搞沒飯吃的我跟你講,因此你仍是得想一想,想盡辦法去實現。

你先忘記前面說的原則,你想一想,不考慮其餘因素,也不是隻能用DOWN事件,只要你能判斷用戶的想法就行,你有什麼辦法

  1. 辦法確定是有的,你能夠多等一會,看用戶接下來的行爲能匹配哪一種操做模式
    1. 點擊操做的模式是這樣:用戶先DOWN,而後MOVE很小一段,也不會MOVE出這個子View,關鍵是比較短的時間就UP
    2. 滑動操做的模式是這樣:用戶先DOWN,而後開始MOVE,這時候可能會MOVE出這個子View,也可能不,但關鍵是比較長的時間也沒有在UP,一直是在MOVE
  2. 因此你的結論是,只有DOWN不行,還得看接下來的事件流,得走着瞧
  3. 再多考慮個長按的狀況,總結就是:
    1. 若是在某個時間內UP,就是點擊裏邊的View
    2. 若是比較長的時間UP,但沒怎麼MOVE,就是長按裏邊的View
    3. 若是在比較短的時間MOVE比較長的距離,就是滑動外面的View

看上去這個目標 View 斷定方案很不錯,安排得明明白白,但咱們現有的事件處理框架實現不了這樣的斷定方案,至少存在如下兩個衝突:

  1. 由於子View和父View都沒法在DOWN的時候判斷當前事件流是否是該給本身,因此一開始它們都只能返回false。但爲了能對後續事件作判斷,你但願事件繼續流過它們,按照當前框架的邏輯,你又不能返回false
  2. 假設事件會流過它們,當事件流了一下子後,父 View 判斷出這符合本身的消費模式啊,因而想把事件給本身消費,但此時子 View 可能已經在消費事件了,而目前的框架是作不到阻止子 View 繼續消費事件的

因此要解決上述的衝突,就確定要對上一版的事件處理框架進行修改,並且看上去一不當心就會大改

  1. 首先看第二個衝突,解決它的一個直接方案是:調整 dispatch() 方法在傳入事件過程當中的人設,讓它不是隻傳遞事件了,還能夠在往裏傳遞事件前進行攔截,可以看狀況攔截下事件並交給本身的 onTouch() 處理
  2. 基於這個解決方案,大概有如下兩個改動相對小的方案調整思路:
    1. 思路一:
      1. 當事件走到可滑動父View的時候,它先攔截並處理事件,並且還把事件給攢着
      2. 當通過了幾個事件
        1. 若是判斷出符合本身的消費模式,那就直接開始本身消費了,也不用繼續攢事件了
        2. 若是判斷出不是本身的消費模式,再把全部攢着的事件一股腦給子 View,觸發裏邊的點擊操做
    2. 思路二:
      1. 全部的 View 只要可能消費事件,就在onTouch()裏對DOWN事件返回true,無論是否識別出當前屬於本身的消費模式
      2. 當事件走到到可滑動父 View 的時候,它先把事件往裏傳,裏邊可能會處理事件,可能不會,可滑動父 View 都暫時不關心
      3. 而後看子 View 是否處理事件
        1. 假如子 View 不處理事件,那啥問題沒有,父 View 直接處理事件就行了
        2. 假如子 View 處理事件,可滑動父View就會繃緊神經暗中觀察乘機而動,觀察事件是否是符合本身的消費模式,一旦發現符合,它就把事件流攔截下來,即便子View也在處理事件,它也不往裏disptach事件了,而是直接給本身的onTouch()
  3. 兩個思路總結一下:
    1. 思路一:外面的父 View 先攔事件,若是判斷攔錯了,再把事件往裏發
    2. 思路二:外面的父 View 先不攔事件,在判斷應該攔的時候,忽然把事件攔下來
  4. 這兩個思路都要對當前框架作改變,看似差很少,但其實仍是有比較明顯的優劣的
    1. 思路一問題比較明顯:
      1. 父 View 把事件攔下來了,而後發現攔錯了再給子 View,但其實子 View 又並不必定能消費事件,這不就是白作一步嗎。等到子View不處理事件,又把事件們還給父View,父View還得繼續處理事件。整個過程不只繁瑣,並且會讓開發者感受到彆扭
      2. 因此這個思路不太行,還得是把事件先給子View
    2. 思路二就相對正常多了,只有一個問題(下一節再講,你能夠猜一猜,這裏我先當沒發現),並且框架要作的改變也不多:
      1. 增長一個攔截方法onIntercept()在父 View 往裏dispatch事件前,開發者能夠覆寫這個方法,加入本身的事件模式分析代碼,而且能夠在肯定要攔截的時候進行攔截
        1. 把分析攔截邏輯抽成一個方法很是合理:何時攔,何時不攔,內裏的邏輯不少,但對外暴露的 API 能夠很小,很是適合抽出去
      2. 在肯定本身要攔截事件的時候,即便裏邊在一開始消費了事件,也不把事件往裏傳了,而是直接給本身的onTouch()

示意圖:

-w400

因而使用思路二,在「三造」的基礎上,修改獲得如下代碼:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            if (onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                if (onIntercept(ev)) {
                    isSelfNeedEvent = true
                    handled = onTouch(ev)
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
        
        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }
}
複製代碼

寫的過程當中增長了一些對細節的處理:

  1. 不只是在DOWN事件的dispatch()前須要攔截,在後續事件中,也須要加入攔截,不然沒法實現中途攔截的目標
  2. 在某一個事件判斷攔截以後,還須要在後續事件中再判斷一次是否要攔截嗎?
    1. 徹底不須要,咱們但願的就是在一次觸摸中,儘量只有1個對象去消費事件,決定是你了,那就不要變
    2. 因此增長一個isSelfNeedEvent記錄本身是否攔截過事件,若是攔截過,後續事件直接就交給本身處理
  3. 在後續事件時,子 View 沒有處理事件,外面也不會再處理了,一樣由於只能有一個 View 處理(Actvity會處理這樣的事件,後面會提到)

這一下代碼是否是看上去瞬間複雜了,但其實只是增長了一個事件攔截機制,對比上一次試造的輪子,會更容易理解。(要是 Markdown 支持代碼塊內自定義着色就行了)

並且對於框架的使用者來講,關注點仍是很是少

  1. 重寫onIntercept()方法,判斷何時須要攔截事件,須要攔截時返回true
  2. 重寫onTouch()方法,若是處理了事件,返回true

1.5. 五造:增長內部事件攔截

上面的處理思路雖然實現了需求,但可能會致使一個問題:裏邊的子 View 接收了一半的事件,可能都已經開始處理並作了一些事情,父 View 突然就不把後續事件給它了,會不會違背用戶操做的直覺?甚至出現更奇怪的現象?

這個問題確實比較麻煩,分兩類狀況討論

  1. 裏邊的 View 接收了一半事件,但尚未真正開始反饋交互,或者在進行能夠被取消的反饋
    1. 好比對於一個可點擊的View,View的默認實現是隻要被touch了,就會有pressed狀態,若是你設置了對應的background,你的 View 就會有高亮效果
    2. 這種高亮即便被中斷也沒事,不會讓用戶感受到奇怪,不信你本身試試微信的聊天列表
    3. 但一個值得注意的點是,若是你只是直接不發送MOVE事件了,這會有問題,就這個按下高亮的例子,若是你只是不傳MOVE事件了,那誰來告訴裏邊的子View取消高亮呢?因此你須要在中斷的時候也傳一個結束事件
      1. 可是,你能直接傳一個UP事件嗎?也是不行的,由於這樣就匹配了裏邊點擊的模式了,會直接觸發一個點擊事件,這顯然不是咱們想要的
      2. 因而外面須要給一個新的事件,這個事件的類型就叫取消事件好了CANCEL
    4. 總結一下,對於這種簡單的可被取消狀況,你能夠這樣去處理:
      1. 在肯定要攔截的時候,在把真正的事件轉發給本身的onTouch()的同時,另外生成一個新的事件發給本身的子View,事件類型是CANCEL,它將是子View收到的最後一個事件
      2. 子View能夠在收到這個事件後,對當前的一些行爲進行取消
  2. 裏邊的View接收了一半事件,已經開始反饋交互了,這種反饋最好不要去取消它,或者說取消了會顯得很怪
    1. 這個時候,事情會複雜一些,並且這個場景發生的遠比你想象中的多,形式也多種多樣,不處理好的後果也比只是讓用戶感受上奇怪要嚴重得多,可能會有的功能會實現不了,下面舉兩個例子
      1. ViewPager裏有三個page,page裏是ScrollViewViewPager能夠橫向滑動,page裏的ScrollView能夠豎向滑動
        1. 若是按前面邏輯,當ViewPager把事件給裏邊ScrollView以後,它也會偷偷觀察,若是你一直是豎向滑動,那沒話說,ViewPager不會觸發攔截事件
        2. 但若是你豎着滑着滑着,手抖了,開始橫滑(或者只是斜滑),ViewPager就會開始緊張,想「組織終於決定是我了嗎?真的假的,那我可就不客氣了」,因而在你斜滑必定距離以後,突然發現,你劃不動ScrollView了,而ViewPager開始動
        3. 緣由就是ScrollView的豎滑被取消了,ViewPager把事件攔下來,開始橫滑
        4. 這個體驗仍是比較怪的,會有種過於靈敏的感受,會讓用戶只能當心翼翼地滑動
      2. 在一個ScrollView裏有一些按鈕,按鈕有長按事件,長按再拖動就能夠移動按鈕
        1. (更常見的例子是一個列表,裏邊的條目能夠長按拖動)
        2. 一樣按前面的邏輯,當你長按後準備拖動按鈕時,你怎麼保證不讓ScrollView把事件攔下來呢?
    2. 因此這類問題是必定要解決的,但要怎麼解決呢
      1. 仍是先從業務上看,從用戶的角度看,當裏邊已經開始作一些特殊處理了,外面應不該該把事件搶走?
        1. 不該該對吧,OK,解決方針就是不該該讓外邊的View搶事件
      2. 因此接下來的問題是:誰先判斷出外邊的View不應搶事件,裏邊的子View仍是外邊的父View?而後怎麼不讓外邊的View搶?
        1. 首先,確定是裏邊的View作出判斷:這個事件,真的,外邊的View你最好別搶,要不用戶不開心了
        2. 而後裏邊就得告知外邊,你別搶了,告知能夠有幾個方式
          1. 外邊搶以前問一下里邊,我能不能搶
          2. 裏邊在肯定這個事件不能被搶以後,從dispatch方法返回一個特別的值給外邊(以前只是truefalse,如今要加一個)
          3. 裏邊經過別的方式通知外邊,你不要搶
        3. 講道理,我以爲三個方式都行,但第三個方式最爲簡單直接,並且對框架沒有過大的改動,Android也使用了這個方式,父View給子View提供了一個方法requestDisallowInterceptTouchEvent(),子View調用它改變父View的一個狀態,同時父View每次在準備攔截前都會判斷這個狀態(固然這個狀態只對當前事件流有效)
        4. 而後,這個狀況還得再注意一點,它應該是向上遞歸的,也就是,在複雜的狀況中,有可能有多個上級在暗中觀察,當裏邊的View決定要處理事件並且不許備交出去的時候,外面全部的暗中觀察的父View都應該把腦殼轉回去

因此,連同上一次試造,總結一下

  1. 對於多個可消費事件的View進行嵌套的狀況,怎麼斷定事件的歸屬會變得很是麻煩,沒法馬上在DOWN事件時就肯定,只能在後續的事件流中進一步判斷
  2. 因而在沒判斷歸屬的時候,先由裏邊的子View消費事件,外面暗中觀察,同時兩方一塊對事件類型作進一步匹配,並準備在匹配成功後對事件流的歸屬進行搶拍
  3. 搶拍是先搶先得
    1. 父親先搶到,發個CANCEL事件給兒子就完了
    2. 兒子先搶到,就得大喊大叫,撒潑耍賴,爸爸們行行好吧,最後得以安心處理事件

另外有幾個值得一提的地方:

  1. 這種先搶先得的方式感受上有點亂來是吧,但目前也沒有想到更好的辦法了,通常都是開發者本身根據實際用戶體驗調整,讓父親或兒子在最適合的時機準確及時地搶到應得的事件
  2. 父View在攔截下事件後,把接下來的事件傳給本身的onTouch()後,onTouch()只會收到後半部分的事件,這樣會不會有問題呢?
    1. 確實直接給後半部分會有問題,因此通常狀況是,在沒攔截的時候就作好若是要處理事件的一些準備工做,以便以後攔截事件了,只使用後半部分事件也能實現符合用戶直覺的反饋

在「四造」的基礎上,修改獲得如下代碼:

interface ViewParent {
    fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)
}

open class MView {
    var parent: ViewParent? = null

    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

open class MViewGroup(private val child: MView) : MView(), ViewParent {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false
    private var isDisallowIntercept = false

    init {
        child.parent = this
    }

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            // add isDisallowIntercept
            if (!isDisallowIntercept && onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                // add isDisallowIntercept
                if (!isDisallowIntercept && onIntercept(ev)) {
                    isSelfNeedEvent = true

                    // add cancel
                    val cancel = MotionEvent.obtain(ev)
                    cancel.action = MotionEvent.ACTION_CANCEL
                    handled = child.dispatch(cancel)
                    cancel.recycle()
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP 
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }
        
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
        isDisallowIntercept = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }

    override fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean) {
        this.isDisallowIntercept = isDisallowIntercept
        parent?.requestDisallowInterceptTouchEvent(isDisallowIntercept)
    }
}
複製代碼

此次改動主要是增長了發出CANCEL事件和requestDisallowInterceptTouchEvent機制

  1. 在發出CANCEL事件時有一個細節:沒有在給 child 分發CANCEL事件的同時繼續把原事件分發給本身的onTouch 2. 這是源碼中的寫法,不是我故意的,多是爲了讓一個事件也只能有一個View處理,避免出現bug
  2. 實現requestDisallowInterceptTouchEvent機制時,增長了ViewParent接口
    1. 不使用這種寫法也行,但使用它從代碼整潔的角度看會更優雅,好比避免反向依賴,並且這也是源碼的寫法,因而直接搬來了

雖然目前整個框架的代碼有點複雜,但對於使用者來講,依然很是簡單,只是在上一版框架的基礎上增長了:

  1. 若是View判斷本身要消費事件,並且執行的是不但願被父View打斷的操做時,須要馬上調用父View的requestDisallowInterceptTouchEvent()方法
  2. 若是在onTouch方法中對事件消費而且作了一些操做,須要注意在收到CANCEL事件時,對操做進行取消

到這裏,事件分發的主要邏輯已經講清楚了,不過還差一段 Activity 中的處理,其實它作的事情相似ViewGroup,只有這幾個區別:

  1. 不會對事件進行攔截
  2. 只要有子View沒有處理的事件,它都會交給本身的onTouch()

因此很少講了,直接補上Activity的麻雀:

open class MActivity(private val childGroup: MViewGroup) {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    open fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()

            handled = childGroup.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) {
                handled = onTouch(ev)
                if (handled) isSelfNeedEvent = true
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                handled = childGroup.dispatch(ev)
            }

            if (!handled) handled = onTouch(ev)
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }

        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製代碼

1.6. 總結

到這裏,咱們終於造好了一個粗糙但不劣質的輪子,源碼的主要邏輯與它的區別不大,具體區別大概有:TouchTarget機制、多點觸控機制、NestedScrolling 機制、處理各類 listener、結合View的狀態進行處理等,相比主要邏輯,它們就沒有那麼重要了,你們能夠自行閱讀源碼,以後有空也會寫關於多點觸控和TouchTarget的內容 (挖坑預警)

輪子的完整代碼能夠在在這裏查看(Java版本) 這個輪子把源碼中與事件分發相關的內容剝離了出來,能看到:

  1. 相比源碼,這份代碼足夠短足夠簡單,那些跟事件分發無關的東西統統不要來干擾我
    1. 長度總共不超過150行,剔除了全部跟事件分發無關的代碼,而且把一些由於其餘細節致使寫得比較複雜的邏輯,用更簡單直接的方式表達了
  2. 相比那段經典的事件分發僞代碼(見附錄),這份代碼又足夠詳細,詳細到能告訴你全部你須要知道的事件分發的具體細節
    1. 那段經典僞代碼只能起到提綱挈領的做用,而這份麻雀代碼雖然極其精簡但它五臟俱全,全到能夠直接跑 —— 你能夠用它進行爲僞佈局,而後觸發觸摸事件

但輪子不是最重要的,最重要的是整個演化的過程。

因此回頭看,你會發現事件分發其實很簡單,它的關鍵不在於「不一樣的事件類型、不一樣的View種類、不一樣的回調方法、方法不一樣的返回值」對事件分發是怎麼影響的。 關鍵在於 「它要實現什麼功能?對實現效果有什麼要求?使用了什麼解決方案?」,從這個角度,就能清晰並且簡單地把事件分發整個流程梳理清楚。

事件分發要實現的功能是:對用戶的觸摸操做進行反饋,使之符合用戶的直覺。

從用戶的直覺出發能獲得這麼兩個要求:

  1. 用戶的一次操做只有一個View去消費
  2. 讓消費事件的View跟用戶的意圖一致

第二個要求是最難實現的,若是有多個View均可以消費觸摸事件,怎麼斷定哪一個View更適合消費,而且把事件交給它。 咱們使用了一套簡單但有效的先到先得策略,讓內外的可消費事件的View擁有近乎平等的競爭消費者的資格:它們都能接收到事件,並在本身斷定應該消費事件的時候去發起競爭申請,申請成功後事件就所有由它消費。

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

2. 測試輪子

可能有人會問,聽你紙上談兵了半天,你講的真的跟源碼同樣嗎,這要是不對我不是虧大了。 問的好,因此接下來我會使用一個測試事件分發的日誌測試框架對這個小麻雀進行簡單的測試,還會有實踐部分真刀真槍地把上面講過的東西練起來。

2.1. 測試框架

測試的思路是經過在每一個事件分發的鉤子中打印日誌來跟蹤事件分發的過程。 因而就須要在不一樣的 View 層級的不一樣鉤子中,針對不一樣的觸摸事件進行不一樣的操做,以製造各類事件分發的場景。

爲了減小重複代碼簡單搭建了一個測試框架(全部代碼都能在此處查看),包括一個能夠代理 View 中這些的操做的接口IDispatchDelegate及其實現類,和一個DispatchConfig統一進行不一樣的場景的配置。 以後建立了使用統一配置和代理操做的 真實控件們SystemViews 和 咱們本身實現的麻雀控件們SparrowViews

DispatchConfig中配置好事件分發的策略後,直接啓動SystemViews中的DelegatedActivity,進行觸摸,使用關鍵字TouchDojo過濾,就能獲得事件分發的跟蹤日誌。 同時,運行SparrowActivityTest中的dispatch()測試方法,也能獲得麻雀控件的事件分發跟蹤日誌。

2.2. 測試過程

場景一

先配置策略,模擬ViewViewGroup都不消費事件的場景:

fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
    return DispatchDelegate(layer)
}
複製代碼

能看到打印的事件分發跟蹤日誌:

[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept_BE |type:down
|layer:SViewGroup |on:Intercept_AF |result(super):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch_BE |type:down
|layer:SView |on:Touch_AF |result(super):false |type:down
|layer:SView |on:Dispatch_AF |result(super):false |type:down
|layer:SViewGroup |on:Touch_BE |type:down
|layer:SViewGroup |on:Touch_AF |result(super):false |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:down
|layer:SActivity |on:Touch_BE |type:down
|layer:SActivity |on:Touch_AF |result(super):false |type:down
|layer:SActivity |on:Dispatch_AF |result(super):false |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move

[move]
...
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SActivity |on:Touch_BE |type:up
|layer:SActivity |on:Touch_AF |result(super):false |type:up
|layer:SActivity |on:Dispatch_AF |result(super):false |type:up
複製代碼
  1. 由於系統控件和麻雀控件打印的日誌如出一轍,因此只貼出一份
  2. 這裏用BE表明 before,表示該方法開始處理事件的時候,用AF表明after,表示該方法結束處理事件的時候,而且打印處理的結果
  3. 從日誌中能清楚看到,當ViewViewGroup都不消費DOWN事件時,後續事件將再也不傳遞給ViewViewGroup

場景二

再配置策略,模擬ViewViewGroup都消費事件,同時ViewGroup在第二個MOVE事件時認爲本身須要攔截事件的場景:

fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
    return DispatchDelegate(
        layer,
        ALL_SUPER,
        // 表示 onInterceptTouchEvent 方法中,DOWN 事件返回 false,第一個 MOVE 事件返回 false,第二個第三個 MOVE 事件返回 true
        EventsReturnStrategy(T_FALSE, arrayOf(T_FALSE, T_TRUE, T_TRUE), T_SUPER), 
        ALL_TRUE
    )
}

fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
    return DispatchDelegate(layer, ALL_SUPER, ALL_SUPER, ALL_TRUE)
}
複製代碼

能看到打印的事件分發跟蹤日誌:

[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept |result(false):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch |result(true):true |type:down
|layer:SView |on:Dispatch_AF |result(super):true |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:down
|layer:SActivity |on:Dispatch_AF |result(super):true |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(false):false |type:move
|layer:SView |on:Dispatch_BE |type:move
|layer:SView |on:Touch |result(true):true |type:move
|layer:SView |on:Dispatch_AF |result(super):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(true):true |type:move
|layer:SView |on:Dispatch_BE |type:cancel
|layer:SView |on:Touch_BE |type:cancel
|layer:SView |on:Touch_AF |result(super):false |type:cancel
|layer:SView |on:Dispatch_AF |result(super):false |type:cancel
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Touch |result(true):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Touch |result(true):true |type:up
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:up
|layer:SActivity |on:Dispatch_AF |result(super):true |type:up
複製代碼
  1. 一樣由於系統控件和麻雀控件打印的日誌如出一轍,因此只貼出一份
  2. 從日誌中能清楚看到,在ViewGroup攔截事件先後,事件是如何分發的

2.3. 測試結果

除了以上場景外,我也模擬了其餘複雜的場景,能看到系統控件和麻雀控件打印的日誌如出一轍,這就說明了麻雀控件中的事件分發邏輯,確實與系統源碼是一致的。

並且從打印的日誌中,能清晰地看到事件分發的軌跡,對理解事件分發過程也有很大的幫助。因此你們若是有須要,也能夠直接使用這個框架像這樣對觸摸事件分發的各類狀況進行調試。

3. 實踐

實際上進行事件分發的實踐時,會包括兩方面內容:

  1. 一方面是就是控制事件的分發。這也是本文講的主要內容
  2. 另外一方面是對事件的處理。核心內容是手勢的識別,好比識別用戶的操做是單擊、雙擊、長按、滑動,這部分也能夠本身手寫,不會太難,但通常場景中咱們均可以使用SDK提供的十分好用的幫助類GestureDetector,它用起來很是方便

時間關係,這部分暫時直接去看另外一篇透鏡《看穿 > NestedScrolling 機制》吧,它提供了過得去的實踐場景。

(以爲對你有幫助的話,不妨點個贊再走呀~ 給做者一點繼續寫下去的動力)

4. 附錄

4.1.事件分發經典僞代碼

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean consume = false;
    if (onInterceptTouchEvent(event)) {
        consume = onTouchEvent(event);
    } else {
        consume = child.dispatchTouchEvent(event);
    }
    return consume;
}
複製代碼
相關文章
相關標籤/搜索