iOS | 事件傳遞及響應(yīng)鏈
試想一下假如你是一臺(tái)手機(jī)??,當(dāng)有人觸摸了屏幕之后,你需要找到他具體觸摸了什么東西,他可能觸摸是一個(gè)按鈕,或一個(gè)列表,也有可能是一個(gè)一不小心的誤觸,你會(huì)設(shè)計(jì)一個(gè)怎么樣的機(jī)制和系統(tǒng)來處理呢?假如有兩個(gè)按鈕重疊了,或者遇到在滾動(dòng)列表上需要拖動(dòng)某個(gè)按鈕的情況,你設(shè)計(jì)的機(jī)制能正常的運(yùn)作嘛?在 iOS 中系統(tǒng)通過 UIKit 已經(jīng)為我們?cè)O(shè)計(jì)好了一套方案,也是本文淺談的內(nèi)容: iOS 中的事件傳遞及響應(yīng)鏈機(jī)制。
誰來響應(yīng)事件
在 UIKit 中我們使用響應(yīng)者對(duì)象(Responder)接收和處理事件。一個(gè)響應(yīng)者對(duì)象一般是 UIResponder 類的實(shí)例,它常見的子類包括 UIView,UIViewController 和 UIApplication,這意味著幾乎所有我們?nèi)粘J褂玫目丶际琼憫?yīng)者,如 UIButton,UILabel 等等。
在 UIResponder 及其子類中,我們是通過有關(guān)觸摸(UITouch)的方法來處理和傳遞事件(UIEvent),具體的方法如下:
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?)
復(fù)制代碼
UIResponder還可以處理UIPress、加速計(jì)、遠(yuǎn)程控制事件,這里僅討論觸摸事件。
在 UITouch 內(nèi),存儲(chǔ)了大量觸摸相關(guān)的數(shù)據(jù),當(dāng)手指在屏幕上移動(dòng)時(shí),所對(duì)應(yīng)的 UITouch 數(shù)據(jù)也會(huì)更新,例如:這個(gè)觸摸是在哪個(gè) window 或者哪個(gè) view 內(nèi)發(fā)生的?當(dāng)前觸摸點(diǎn)的坐標(biāo)是?前一個(gè)觸摸點(diǎn)的坐標(biāo)是?當(dāng)前觸摸事件的狀態(tài)是?這些都存儲(chǔ)在 UITouch 里面。另外需要注意的是,在這四個(gè)方法的參數(shù)中,傳遞的是 UITouch 類型的一個(gè)集合(而不是一個(gè) UITouch),這對(duì)應(yīng)了兩根及以上手指觸摸同一個(gè)視圖的情況。
確定第一響應(yīng)者
當(dāng)有人用觸摸了屏幕之后,我們需要找到使用者到底觸摸了一個(gè)什么東西,或者可以理解為我們要找到,在這次使用者觸摸之后,使用者最想要哪個(gè)控件發(fā)起響應(yīng)。這個(gè)過程就是確定這次觸摸事件的第一響應(yīng)者是誰。
在觸摸發(fā)生后,UIApplication 會(huì)觸發(fā) func sendEvent(_ event: UIEvent) 將一個(gè)封裝好的 UIEvent 傳給 UIWindow,也就是當(dāng)前展示的 UIWindow,通常情況接下來會(huì)傳給當(dāng)前展示的 UIViewController,接下來傳給 UIViewController 的根視圖。這個(gè)過程是一條龍服務(wù),沒有分叉。但是在傳遞給當(dāng)前 UIViewController 的根視圖之后,就是開發(fā)人員的主戰(zhàn)場(chǎng),視圖的層級(jí)結(jié)構(gòu)就可以變得錯(cuò)綜復(fù)雜起來了。
這里我們使用
UIView來作為視圖層級(jí)的主要組成元素,便于理解。但不止UIView可以響應(yīng)事件,實(shí)際只要是UIResponder的子類,都可以響應(yīng)和傳遞事件。后文會(huì)大量用視圖或UIView來舉例,實(shí)則代指一個(gè)合格的響應(yīng)者。
回到開頭的問題,我現(xiàn)在變成了一臺(tái)手機(jī)??,并且我知道有人觸摸了屏幕。我所擁有的信息是觸摸點(diǎn)的坐標(biāo),我知道應(yīng)該就是視圖層級(jí)中其中的某一個(gè),但我無法直接知道用戶是想點(diǎn)哪個(gè)視圖。我需要一個(gè)策略來找到這個(gè)第一響應(yīng)者,UIKit 為我們提供了命中測(cè)試(hit-testing)來確定觸摸事件的響應(yīng)者,這個(gè)策略具體是這樣運(yùn)作的:
命中測(cè)試
關(guān)于圖中還有一些細(xì)節(jié)需要先說明:
- 在
檢查自身可否接收事件中,如果視圖符合以下三個(gè)條件中的任一個(gè),都會(huì)無法接收事件:view.isUserInteractionEnabled = falseview.alpha <= 0.01view.isHidden = true
-
檢查坐標(biāo)是否在自身內(nèi)部這個(gè)過程使用了func point(inside point: CGPoint, with event: UIEvent?) -> Bool方法來判斷坐標(biāo)是否在自身內(nèi)部,該方法是可以被重寫的。 -
從后往前遍歷子視圖重復(fù)執(zhí)行指的是按照FILO的原則,將其所有子視圖按照「后添加的先遍歷」的規(guī)則進(jìn)行命中測(cè)試。該規(guī)則保證了系統(tǒng)會(huì)優(yōu)先測(cè)試視圖層級(jí)樹中最后添加的視圖,如果視圖之間有重疊,該視圖也是同級(jí)視圖中展示最完整的視圖,即用戶最可能想要點(diǎn)的那個(gè)視圖。 - 在
按順序看看平級(jí)的兄弟視圖時(shí),若發(fā)現(xiàn)已經(jīng)沒有未檢查過的視圖了,則應(yīng)走向誒?沒有子視圖符合要求?。
下面我們舉個(gè)例子來解釋這個(gè)流程,在例子中我們從當(dāng)前 UIViewController 的根視圖開始執(zhí)行這個(gè)流程。下圖中灰色視圖 A 可以看作是當(dāng)前 UIViewController 的根視圖,右側(cè)表示了各個(gè)視圖的層級(jí)結(jié)構(gòu),用戶在屏幕上的觸摸點(diǎn)是??處,并且這 5 個(gè)視圖都可以正常的接收事件。??并且注意,D 比 B 更晚添加到 A 上。
具體的流程如下:
- 首先對(duì) A 進(jìn)行命中測(cè)試,顯然??是在 A 內(nèi)部的,按照流程接下來檢查 A 是否有子視圖。
- 我們發(fā)現(xiàn) A 有兩個(gè)子視圖,那我們就需要按
FILO原則遍歷子視圖,先對(duì) D 進(jìn)行命中測(cè)試,后對(duì) B 進(jìn)行命中測(cè)試。 - 我們對(duì) D 進(jìn)行命中測(cè)試,我們發(fā)現(xiàn)??不在 D 的內(nèi)部,那就說明 D 及其子視圖一定不是第一響應(yīng)者。
- 按順序接下來對(duì) B 進(jìn)行命中測(cè)試,我們發(fā)現(xiàn)??在 B 的內(nèi)部,按照流程接下來檢查 B 是否有子視圖。
- 我們發(fā)現(xiàn) B 有一個(gè)子視圖 C,所以需要對(duì) C 進(jìn)行命中測(cè)試。
- 顯然??不在 C 的內(nèi)部,這時(shí)我們得到的信息是:觸摸點(diǎn)在 B 的內(nèi)部,但不在 B 的任一子視圖內(nèi)。
- 得到結(jié)論:B 是第一響應(yīng)者,并且結(jié)束命中測(cè)試。
- 整個(gè)命中測(cè)試的走向是這樣的:A? --> D? --> B? --> C? >>>> B
整個(gè)流程應(yīng)該算是清晰明了??,實(shí)際上這個(gè)流程就是 UIView 的一個(gè)方法:func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?,方法最后返回的 UIView? 即第一響應(yīng)者,這個(gè)方法代碼還原應(yīng)該是這樣的:
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) { // 判斷觸摸點(diǎn)是否在自身內(nèi)部
for subview in subviews.reversed() { // 按 FILO 遍歷子視圖
let convertedPoint = subview.convert(point, from: self)
let resultView = subview.hitTest(convertedPoint, with: event)
// ??這句是判斷觸摸點(diǎn)是否在子視圖內(nèi)部,在就返回視圖,不在就返回nil
if resultView != nil { return resultView }
}
return self // 此處指該視圖的所有子視圖都不符合要求,而觸摸點(diǎn)又在該視圖自身內(nèi)部
}
return nil // 此處指觸摸點(diǎn)是否不在該視圖內(nèi)部
}
}
復(fù)制代碼
小心越界!
針對(duì)這個(gè)流程舉個(gè)額外的例子,如果按下圖的視圖層級(jí)和觸摸點(diǎn)來判斷的話,最終獲得第一響應(yīng)者仍然是 B,甚至整個(gè)命中測(cè)試的走向和之前是一樣的:A? --> D? --> B? --> C? >>>> B,究其原因是在 D 檢查觸摸點(diǎn)是否在自身內(nèi)部時(shí),答案是否,所以不會(huì)去對(duì) E 進(jìn)行命中測(cè)試,即使看起來我們點(diǎn)了 E。這個(gè)例子告訴我們,要注意可點(diǎn)擊的子視圖是否會(huì)超出父視圖的范圍。另若有這種情況可以重寫 func point(inside point: CGPoint, with event: UIEvent?) -> Bool 方法來擴(kuò)大點(diǎn)擊有效范圍。對(duì)于這種處理方式,個(gè)人覺得是可以,但沒必要,尋求合理的視圖布局和清晰易讀的代碼比這個(gè)關(guān)鍵??。
通過響應(yīng)鏈傳遞事件
確定響應(yīng)鏈成員
在找到了第一響應(yīng)者之后,整個(gè)響應(yīng)鏈也隨著確定下來了。所謂響應(yīng)鏈?zhǔn)怯身憫?yīng)者組成的一個(gè)鏈表,鏈表的頭是第一響應(yīng)者,鏈表的每個(gè)結(jié)點(diǎn)的下一個(gè)結(jié)點(diǎn)都是該結(jié)點(diǎn)的 next 屬性。
其實(shí)響應(yīng)鏈就是在命中測(cè)試中,走通的路徑。用上個(gè)章節(jié)的例子,整個(gè)命中測(cè)試的走向是:A? --> D? --> B? --> C?,我們把沒走通的?的去掉,以第一響應(yīng)者 B 作為頭,依次連接,響應(yīng)鏈就是:B -> A。(實(shí)際上 A 后面還有控制器等,但在該例子中沒有展示控制器等,所以就寫到 A)
默認(rèn)來說,若該結(jié)點(diǎn)是 UIView 類型的話,這個(gè) next 屬性是該結(jié)點(diǎn)的父視圖。但也有幾個(gè)例外:
- 如果是
UIViewController的根視圖,則下一個(gè)響應(yīng)者是UIViewController。 - 如果是
UIViewController- 如果
UIViewController的視圖是UIWindow的根視圖,則下一個(gè)響應(yīng)者是UIWindow對(duì)象。 - 如果
UIViewController是由另一個(gè)UIViewController呈現(xiàn)的,則下一個(gè)響應(yīng)者是第二個(gè)UIViewController。
- 如果
-
UIWindow的下一個(gè)響應(yīng)者是UIApplication。 -
UIApplication的下一個(gè)響應(yīng)者是app delegate。但僅當(dāng)該app delegate是UIResponder的實(shí)例且不是UIView、UIViewController或 app 對(duì)象本身時(shí),才是下一個(gè)響應(yīng)者。
下面舉個(gè)例子來說明。如下圖所示,觸摸點(diǎn)是??,那根據(jù)命中測(cè)試,B 就成為了第一響應(yīng)者。由于 C 是 B 的父視圖、A 是 C 的父視圖、同時(shí) A 是 Controller 的根視圖,那么按照規(guī)則,響應(yīng)鏈就是這樣的:
視圖 B -> 視圖 C -> 根視圖 A -> UIViewController 對(duì)象 -> UIWindow 對(duì)象 -> UIApplication 對(duì)象 -> App Delegate
圖中淺灰色的箭頭是指將
UIView直接添加到UIWindow上情況。
沿響應(yīng)鏈傳遞事件
觸摸事件首先將會(huì)由第一響應(yīng)者響應(yīng),觸發(fā)其 open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) 等方法,根據(jù)觸摸的方式不同(如拖動(dòng),雙指),具體的方法和過程也不一樣。若第一響應(yīng)者在這個(gè)方法中不處理這個(gè)事件,則會(huì)傳遞給響應(yīng)鏈中的下一個(gè)響應(yīng)者觸發(fā)該方法處理,若下一個(gè)也不處理,則以此類推傳遞下去。若到最后還沒有人響應(yīng),則會(huì)被丟棄(比如一個(gè)誤觸)。 我們可以創(chuàng)建一個(gè) UIView 的子類,并加入一些打印函數(shù),來觀察響應(yīng)鏈具體的工作流程。
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)
}
}
復(fù)制代碼
下面我們舉一個(gè)例子。如下圖,A B C 都是 UIView,我們將手指按照??的位置和箭頭的方向在屏幕上移動(dòng)一段距離,然后松開手。我們應(yīng)該能在控制臺(tái)看到下右圖的輸出。我們可以看到,A B C 三個(gè)視圖都積極的響應(yīng)了每一次事件,每次觸摸的發(fā)生后,都會(huì)先觸發(fā) B 的響應(yīng)方法,然后傳遞給 C,在傳遞給 A。但是這種「積極」的響應(yīng)其實(shí)意味著在我們這個(gè)例子中,A B C 都不是這個(gè)觸摸事件的合適接受者。他們之所以「積極」的將事件傳遞下去,是因?yàn)樗麄儾榭戳诉@個(gè)事件的信息之后,認(rèn)為自己并不是這個(gè)事件的合適處理者。(當(dāng)然了,我們這邊放的是三個(gè) UIView,他們本身確實(shí)也不應(yīng)該能處理事件)
[圖片上傳失敗...(image-587806-1607390845688)]
那么如果我們把上圖中的 C 換成平時(shí)使用的 UIControl 類,控制臺(tái)又會(huì)怎么打印呢?如右下圖所示,會(huì)發(fā)現(xiàn)響應(yīng)鏈的事件傳遞到 C 處就停止了,也就是 A 的 touches 方法沒有被觸發(fā)。這意味著在響應(yīng)鏈中,UIControl 及其子類默認(rèn)來說,是不會(huì)將事件傳遞下去的。在代碼中,可以理解為 UIView 默認(rèn)會(huì)在其 touches 方法中去調(diào)用其 next 的 touches 方法,而 UIControl 默認(rèn)不會(huì)去調(diào)用。這樣就做到了,當(dāng)某個(gè)控件接受了事件之后,事件的傳遞就會(huì)終止。另外,UIScrollView 也是這樣的工作機(jī)制。
[圖片上傳失敗...(image-f11909-1607390845688)]
UIControl接收信息的機(jī)制是target-action機(jī)制,和UIGestureRecognizer的處理方式完全不一樣,在下篇響應(yīng)鏈x手勢(shì)的文章中會(huì)談到區(qū)別。
當(dāng)然,我們其實(shí)可以繼承 UIView,來制作一個(gè)既處理事件,又繼續(xù)傳遞事件的 View。又或是繼承 UIControl,在合適的時(shí)機(jī)觸發(fā) next 的對(duì)應(yīng) touches 方法,也能做到相同效果。只是做之前要想清楚????,你是不是真的要把一個(gè)事件發(fā)放給多個(gè)控件來處理?當(dāng)控件的層級(jí)關(guān)系重新排列時(shí),效果還是否正確?你是不是單純想搞事?等問題。
總結(jié)
總的來說,觸摸屏幕后事件的傳遞可以分為以下幾個(gè)步驟:
- 通過命中測(cè)試來找到「第一響應(yīng)者」
- 由「第一響應(yīng)者」來確定「響應(yīng)鏈」
- 將事件沿「響應(yīng)鏈」傳遞
- 事件被某個(gè)響應(yīng)者接收,或沒有響應(yīng)者接收從而被丟棄
這些步驟都是建立在不使用 UIGestureRecognizer的基礎(chǔ)上的,下一篇文章會(huì)談一下響應(yīng)鏈x手勢(shì)的情況。