深入理解iOS中的事件傳遞及響應鏈

前言:

試想一下假如你是一臺手機📟,當有人觸摸了屏幕之後,你需要找到他具體觸摸了什麼東西,他可能觸摸是一個按鈕,或一個列表,也有可能是一個一不小心的誤觸,你會設計一個怎麼樣的機制和系統來處理呢?假如有兩個按鈕重疊了,或者遇到在滾動列表上需要拖動某個按鈕的情況,你設計的機制能正常的運作嘛?在 iOS 中系統通過 UIKit 已經爲我們設計好了一套方案,也是本文淺談的內容: iOS 中的事件傳遞及響應鏈機制。

誰來響應事件

在 UIKit 中我們使用響應者對象(Responder)接收和處理事件。一個響應者對象一般是 UIResponder 類的實例,它常見的子類包括 UIViewUIViewControllerUIApplication,這意味着幾乎所有我們日常使用的控件都是響應者,如 UIButtonUILabel 等等。

UIResponder 及其子類中,我們是通過有關觸摸(UITouch)的方法來處理和傳遞事件(UIEvent),具體的方法如下:

作爲一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流羣:413038000,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!

open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

UIResponder 還可以處理 UIPress、加速計、遠程控制事件,這裏僅討論觸摸事件。

UITouch 內,存儲了大量觸摸相關的數據,當手指在屏幕上移動時,所對應的 UITouch 數據也會更新,例如:這個觸摸是在哪個 window 或者哪個 view 內發生的?當前觸摸點的座標是?前一個觸摸點的座標是?當前觸摸事件的狀態是?這些都存儲在 UITouch 裏面。另外需要注意的是,在這四個方法的參數中,傳遞的是 UITouch 類型的一個集合(而不是一個 UITouch),這對應了兩根及以上手指觸摸同一個視圖的情況。

確定第一響應者

當有人用觸摸了屏幕之後,我們需要找到使用者到底觸摸了一個什麼東西,或者可以理解爲我們要找到,在這次使用者觸摸之後,使用者最想要哪個控件發起響應。這個過程就是確定這次觸摸事件的第一響應者是誰。

在觸摸發生後,UIApplication 會觸發 func sendEvent(_ event: UIEvent) 將一個封裝好的 UIEvent 傳給 UIWindow,也就是當前展示的 UIWindow,通常情況接下來會傳給當前展示的 UIViewController,接下來傳給 UIViewController 的根視圖。這個過程是一條龍服務,沒有分叉。但是在傳遞給當前 UIViewController 的根視圖之後,就是開發人員的主戰場,視圖的層級結構就可以變得錯綜複雜起來了。

這裏我們使用 UIView 來作爲視圖層級的主要組成元素,便於理解。但不止 UIView 可以響應事件,實際只要是 UIResponder 的子類,都可以響應和傳遞事件。後文會大量用 視圖UIView 來舉例,實則代指一個合格的響應者。

回到開頭的問題,我現在變成了一臺手機📱,並且我知道有人觸摸了屏幕。我所擁有的信息是觸摸點的座標,我知道應該就是視圖層級中其中的某一個,但我無法直接知道用戶是想點哪個視圖。我需要一個策略來找到這個第一響應者,UIKit 爲我們提供了命中測試(hit-testing)來確定觸摸事件的響應者,這個策略具體是這樣運作的:

命中測試

關於圖中還有一些細節需要先說明:

  • 檢查自身可否接收事件 中,如果視圖符合以下三個條件中的任一個,都會無法接收事件:
    1. view.isUserInteractionEnabled = false
    2. view.alpha <= 0.01
    3. view.isHidden = true
  • 檢查座標是否在自身內部 這個過程使用了 func point(inside point: CGPoint, with event: UIEvent?) -> Bool 方法來判斷座標是否在自身內部,該方法是可以被重寫的。
  • 從後往前遍歷子視圖重複執行 指的是按照 FILO 的原則,將其所有子視圖按照「後添加的先遍歷」的規則進行命中測試。該規則保證了系統會優先測試視圖層級樹中最後添加的視圖,如果視圖之間有重疊,該視圖也是同級視圖中展示最完整的視圖,即用戶最可能想要點的那個視圖。
  • 按順序看看平級的兄弟視圖 時,若發現已經沒有未檢查過的視圖了,則應走向 誒?沒有子視圖符合要求?

下面我們舉個例子來解釋這個流程,在例子中我們從當前 UIViewController 的根視圖開始執行這個流程。下圖中灰色視圖 A 可以看作是當前 UIViewController 的根視圖,右側表示了各個視圖的層級結構,用戶在屏幕上的觸摸點是🌟處,並且這 5 個視圖都可以正常的接收事件。⚠️並且注意,D 比 B 更晚添加到 A 上。

具體的流程如下:

  • 首先對 A 進行命中測試,顯然🌟是在 A 內部的,按照流程接下來檢查 A 是否有子視圖。
  • 我們發現 A 有兩個子視圖,那我們就需要按 FILO 原則遍歷子視圖,先對 D 進行命中測試,後對 B 進行命中測試。
  • 我們對 D 進行命中測試,我們發現🌟不在 D 的內部,那就說明 D 及其子視圖一定不是第一響應者。
  • 按順序接下來對 B 進行命中測試,我們發現🌟在 B 的內部,按照流程接下來檢查 B 是否有子視圖。
  • 我們發現 B 有一個子視圖 C,所以需要對 C 進行命中測試。
  • 顯然🌟不在 C 的內部,這時我們得到的信息是:觸摸點在 B 的內部,但不在 B 的任一子視圖內。
  • 得到結論:B 是第一響應者,並且結束命中測試。
  • 整個命中測試的走向是這樣的:A✅ --> D❎ --> B✅ --> C❎ >>>> B

整個流程應該算是清晰明瞭🐶,實際上這個流程就是 UIView 的一個方法:func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?,方法最後返回的 UIView? 即第一響應者,這個方法代碼還原應該是這樣的:

class HitTestExampleView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
            return nil // 此處指視圖無法接受事件
        }
        if self.point(inside: point, with: event) { // 判斷觸摸點是否在自身內部
            for subview in subviews.reversed() { // 按 FILO 遍歷子視圖
                let convertedPoint = subview.convert(point, from: self)
                let resultView = subview.hitTest(convertedPoint, with: event) 
                // ⬆️這句是判斷觸摸點是否在子視圖內部,在就返回視圖,不在就返回nil
                if resultView != nil { return resultView }
            }
            return self // 此處指該視圖的所有子視圖都不符合要求,而觸摸點又在該視圖自身內部
        }
        return nil // 此處指觸摸點是否不在該視圖內部
    }
}
複製代碼

小心越界!

針對這個流程舉個額外的例子,如果按下圖的視圖層級和觸摸點來判斷的話,最終獲得第一響應者仍然是 B,甚至整個命中測試的走向和之前是一樣的:A✅ --> D❎ --> B✅ --> C❎ >>>> B,究其原因是在 D 檢查觸摸點是否在自身內部時,答案是否,所以不會去對 E 進行命中測試,即使看起來我們點了 E。這個例子告訴我們,要注意可點擊的子視圖是否會超出父視圖的範圍。另若有這種情況可以重寫 func point(inside point: CGPoint, with event: UIEvent?) -> Bool 方法來擴大點擊有效範圍。對於這種處理方式,個人覺得是可以,但沒必要,尋求合理的視圖佈局和清晰易讀的代碼比這個關鍵💪。

通過響應鏈傳遞事件

確定響應鏈成員

在找到了第一響應者之後,整個響應鏈也隨着確定下來了。所謂響應鏈是由響應者組成的一個鏈表,鏈表的頭是第一響應者,鏈表的每個結點的下一個結點都是該結點的 next 屬性。

其實響應鏈就是在命中測試中,走通的路徑。用上個章節的例子,整個命中測試的走向是:A✅ --> D❎ --> B✅ --> C❎,我們把沒走通的❎的去掉,以第一響應者 B 作爲頭,依次連接,響應鏈就是:B -> A。(實際上 A 後面還有控制器等,但在該例子中沒有展示控制器等,所以就寫到 A)

默認來說,若該結點是 UIView 類型的話,這個 next 屬性是該結點的父視圖。但也有幾個例外:

  • 如果是 UIViewController 的根視圖,則下一個響應者是 UIViewController
  • 如果是 UIViewController
    • 如果 UIViewController 的視圖是 UIWindow 的根視圖,則下一個響應者是 UIWindow 對象。
    • 如果 UIViewController 是由另一個 UIViewController 呈現的,則下一個響應者是第二個 UIViewController
  • UIWindow的下一個響應者是 UIApplication
  • UIApplication 的下一個響應者是 app delegate。但僅當該 app delegateUIResponder 的實例且不是 UIViewUIViewController 或 app 對象本身時,纔是下一個響應者。

下面舉個例子來說明。如下圖所示,觸摸點是🌟,那根據命中測試,B 就成爲了第一響應者。由於 C 是 B 的父視圖、A 是 C 的父視圖、同時 A 是 Controller 的根視圖,那麼按照規則,響應鏈就是這樣的:

視圖 B -> 視圖 C -> 根視圖 A -> UIViewController 對象 -> UIWindow 對象 -> UIApplication 對象 -> App Delegate

圖中淺灰色的箭頭是指將 UIView 直接添加到 UIWindow 上情況。

沿響應鏈傳遞事件

觸摸事件首先將會由第一響應者響應,觸發其 open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) 等方法,根據觸摸的方式不同(如拖動,雙指),具體的方法和過程也不一樣。若第一響應者在這個方法中不處理這個事件,則會傳遞給響應鏈中的下一個響應者觸發該方法處理,若下一個也不處理,則以此類推傳遞下去。若到最後還沒有人響應,則會被丟棄(比如一個誤觸)。 我們可以創建一個 UIView 的子類,並加入一些打印函數,來觀察響應鏈具體的工作流程。

class TouchesExampleView: UIView {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("Touches Began on " + colorBlock)
        super.touchesBegan(touches, with: event)
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("Touches Moved on " + colorBlock)
        super.touchesMoved(touches, with: event)
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("Touches Ended on " + colorBlock)
        super.touchesEnded(touches, with: event)
    }
}
複製代碼

下面我們舉一個例子。如下圖,A B C 都是 UIView,我們將手指按照🌟的位置和箭頭的方向在屏幕上移動一段距離,然後鬆開手。我們應該能在控制檯看到下右圖的輸出。我們可以看到,A B C 三個視圖都積極的響應了每一次事件,每次觸摸的發生後,都會先觸發 B 的響應方法,然後傳遞給 C,在傳遞給 A。但是這種「積極」的響應其實意味着在我們這個例子中,A B C 都不是這個觸摸事件的合適接受者。他們之所以「積極」的將事件傳遞下去,是因爲他們查看了這個事件的信息之後,認爲自己並不是這個事件的合適處理者。(當然了,我們這邊放的是三個 UIView,他們本身確實也不應該能處理事件)

那麼如果我們把上圖中的 C 換成平時使用的 UIControl 類,控制檯又會怎麼打印呢?如右下圖所示,會發現響應鏈的事件傳遞到 C 處就停止了,也就是 A 的 touches 方法沒有被觸發。這意味着在響應鏈中,UIControl 及其子類默認來說,是不會將事件傳遞下去的。在代碼中,可以理解爲 UIView 默認會在其 touches 方法中去調用其 next 的 touches 方法,而 UIControl 默認不會去調用。這樣就做到了,當某個控件接受了事件之後,事件的傳遞就會終止。另外,UIScrollView 也是這樣的工作機制。

UIControl 接收信息的機制是 target-action 機制,和 UIGestureRecognizer 的處理方式完全不一樣,在下篇響應鏈x手勢的文章中會談到區別。

當然,我們其實可以繼承 UIView,來製作一個既處理事件,又繼續傳遞事件的 View。又或是繼承 UIControl,在合適的時機觸發 next 的對應 touches 方法,也能做到相同效果。只是做之前要想清楚⚠️🍄,你是不是真的要把一個事件發放給多個控件來處理?當控件的層級關係重新排列時,效果還是否正確?你是不是單純想搞事?等問題。

總結

總的來說,觸摸屏幕後事件的傳遞可以分爲以下幾個步驟:

  1. 通過命中測試來找到「第一響應者」
  2. 由「第一響應者」來確定「響應鏈」
  3. 將事件沿「響應鏈」傳遞
  4. 事件被某個響應者接收,或沒有響應者接收從而被丟棄

這些步驟都是建立在不使用 UIGestureRecognizer 的基礎上的,下一篇文章會談一下響應鏈x手勢的情況。

作爲一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流羣:413038000,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!

作者:Mim0sa
鏈接:https://juejin.cn/post/6894518925514997767
來源:掘金

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章