注:根據史上最詳細的iOS之事件的傳遞和響應機制-原理篇重新整理(適當刪減及補充)。
在 iOS 中,只有繼承了 UIReponder
(響應者)類的物件才能接收並處理事件。其公共子類包括 UIView
、UIViewController
和 UIApplication
。
UIReponder
類中提供了以下 4 個物件方法來處理觸控事件:
/// 觸控開始
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 觸控移動
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 觸控取消(在觸控結束之前)
/// 某個系統事件(例如電話呼入)會打斷觸控過程
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 觸控結束
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
複製程式碼
注意:
如果手指同時觸控螢幕,
touches(_:with:)
方法只會呼叫一次,Set<UITouch>
包含兩個物件;如果手指前後觸控螢幕,
touches(_:with:)
會依次呼叫,且每次呼叫時Set<UITouch>
只有一個物件。
iOS 中的事件傳遞
事件傳遞和響應的整個流程
- 觸發事件後,系統會將該事件加入到一個由
UIApplication
管理的事件佇列中; UIApplication
會從事件佇列中取出最前面的事件,將之分發出去以便處理,通常,先傳送事件給應用程式的主視窗(keyWindow
);- 主視窗會在檢視層次結構中找到一個最適合的檢視來處理觸控事件;
- 找到適合的檢視控制元件後,就會呼叫該檢視控制元件的
touches(_:with:)
方法; touches(_:with:)
的預設實現是將事件順著響應者鏈(後面會說)一直傳遞下去,直到連UIApplication
物件也不能響應事件,則將其丟棄。
如何尋找最適合的控制元件來處理事件
當事件觸發後,系統會呼叫控制元件的 hitTest(_:with:)
方法來遍歷檢視的層次結構,以確定哪個子檢視應該接收觸控事件,過程如下:
- 呼叫自己的
hitTest(_:with:)
方法; - 判斷自己能否觸發事件、是否隱藏、alpha <= 0.01;
- 呼叫
point(inside:with:)
來判斷觸控點是否在自己身上; - 倒序遍歷
subviews
,並重復前面三個步驟。直到找到包含觸控點的最上層檢視,並返回這個檢視,那麼該檢視就是那個最適合的處理事件的 view; - 如果沒有符合條件的子控制元件,就認為自己最適合處理事件,也就是自己是最適合的 view;
通俗一點來解釋就是,其實系統也無法決定應該讓哪個檢視處理事件,那麼就用遍歷的方式,依次找到包含觸控點所在的最上層檢視,則認為該檢視最適合處理事件。
注意:
觸控事件傳遞的過程是從父控制元件傳遞到子控制元件的,如果父控制元件也不能接收事件,那麼子控制元件就不可能接收事件。
尋找最適合的的 view 的底層剖析
-
hitTest(_:with:)
的呼叫時機- 事件開始產生時會呼叫;
- 只要事件傳遞給一個控制元件,就會呼叫這個控制元件的
hitTest(_:with:)
方法(不管這個控制元件能否處理事件或觸控點是否自己身上)。
-
hitTest(_:with:)
的作用返回一個最適合的 view 來處理觸控事件。
注意:
如果
hitTest(_:with:)
方法中返回nil
,那麼該控制元件本身和其subview
都不是最適合的 view,而是該控制元件的父控制元件。在預設的實現中,如果確定最終父控制元件是最適合的 view,那麼仍然會呼叫其子控制元件的
hitTest(_:with:)
方法(不然怎麼知道有沒有更適合的 view?參考 如何尋找最適合的控制元件來處理事件。)
hitTest(_:with:)
的預設實現
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1. 判斷自己能否觸發事件
if !self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01 {
return nil
}
// 2.判斷觸控點是否在自己身上
if !self.point(inside: point, with: event) {
return nil
}
// 3. 倒序遍歷 `subviews` ,並重復前面兩個步驟;
// 直到找到包含觸控點的最前面的檢視,並返回這個檢視,那麼該檢視就是那個最合適的接收事件的 view;
for view in subviews.reversed() {
// 把座標轉換成控制元件上的座標
let p = self.convert(point, to: view)
if let hitView = view.hitTest(p, with: event) {
return hitView
}
}
return self
}
複製程式碼
iOS 中的事件響應
找到最適合的 view 接收事件後,如果不重寫實現該 view 的 touches(_:with:)
方法,那麼這些方法的預設實現是將事件順著響應者鏈向下傳遞, 將事件交給下一個響應者去處理。
可以說,響應者鏈是由多個響應者物件連結起來的鏈條。UIReponder
的一個物件屬性 next
能夠很好的解釋這一規則。
UIReponder().next
返回響應者鏈中的下一個響應者,如果沒有下一個響應者,則返回 nil
。
例如,UIView
呼叫此屬性會返回管理它的 UIViewController
物件(如果有),沒有則返回它的 superview
;UIViewController
呼叫此屬性會返回其檢視的 superview
;UIWindow
返回應用程式物件;共享的 UIApplication
物件則通常返回 nil
。
例如,我們可以通過 UIView
的 next
屬性找到它所在的控制器:
extension UIView {
var next = self.next
while next != nil { // 符合條件就一直迴圈
if let viewController = next as? UIViewController {
return viewController
}
// UIView 的下一個響應控制元件,直到找到控制器。
next = next?.next
}
return nil
}
複製程式碼