前言:
試想一下假如你是一臺手機📟,當有人觸摸了屏幕之後,你需要找到他具體觸摸了什麼東西,他可能觸摸是一個按鈕,或一個列表,也有可能是一個一不小心的誤觸,你會設計一個怎麼樣的機制和系統來處理呢?假如有兩個按鈕重疊了,或者遇到在滾動列表上需要拖動某個按鈕的情況,你設計的機制能正常的運作嘛?在 iOS 中系統通過 UIKit 已經爲我們設計好了一套方案,也是本文淺談的內容: iOS 中的事件傳遞及響應鏈機制。
誰來響應事件
在 UIKit 中我們使用響應者對象(Responder)接收和處理事件。一個響應者對象一般是 UIResponder
類的實例,它常見的子類包括 UIView
,UIViewController
和 UIApplication
,這意味着幾乎所有我們日常使用的控件都是響應者,如 UIButton
,UILabel
等等。
在 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)來確定觸摸事件的響應者,這個策略具體是這樣運作的:
命中測試
關於圖中還有一些細節需要先說明:
- 在
檢查自身可否接收事件
中,如果視圖符合以下三個條件中的任一個,都會無法接收事件:view.isUserInteractionEnabled = false
view.alpha <= 0.01
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 delegate
是UIResponder
的實例且不是UIView
、UIViewController
或 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 方法,也能做到相同效果。只是做之前要想清楚⚠️🍄,你是不是真的要把一個事件發放給多個控件來處理?當控件的層級關係重新排列時,效果還是否正確?你是不是單純想搞事?等問題。
總結
總的來說,觸摸屏幕後事件的傳遞可以分爲以下幾個步驟:
- 通過命中測試來找到「第一響應者」
- 由「第一響應者」來確定「響應鏈」
- 將事件沿「響應鏈」傳遞
- 事件被某個響應者接收,或沒有響應者接收從而被丟棄
這些步驟都是建立在不使用 UIGestureRecognizer
的基礎上的,下一篇文章會談一下響應鏈x手勢的情況。
作爲一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流羣:413038000,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!
作者:Mim0sa
鏈接:https://juejin.cn/post/6894518925514997767
來源:掘金