一、概述
iOS 響應者鏈(Responder Chain)是支撐 App 界面交互的重要基礎,點擊、滑動、旋轉、搖晃等都離不開其背后的響應者鏈鏈。
簡單的說(雖然不準確),響應者鏈的作用就是讓 APP 知道用戶點擊里了哪里,然后應該哪個控件做出反應。專業點說,響應者鏈就是由多個響應者組合起來的鏈條,就叫做響應者鏈。它表示了每個響應者之間的聯系,并且可以使得一個事件可選擇多個對象處理。
二、事件的產生和傳遞
當一個觸摸事件產生的時候,程序是如何找到第一響應者的呢?也就是說程序怎么知道點擊了哪個控件呢?
當點擊了屏幕會產生一個觸摸事件,消息循環(runloop)會接收到觸摸事件放到消息隊列里,UIApplication 會從消息隊列里取事件分發下去,接著需要找到去響應這個事件的最佳視圖,也就是 Responder,所以開始的第一步應該是找到 Responder,那么又是如何找到的呢?那就不得不引出 UIView 的 2 個方法:
// 返回此次觸摸事件初始點所在的視圖
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
// 返回視圖是否包含指定的某個點
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
通過在顯示視圖層級中依次對視圖調用這個 2 個方法來確認該視圖是不是能響應這個點擊的點,首先會調用 hitTest
,然后在 hitTest
中調用 pointInside
,最終 hitTest
返回的那個 view
就是最終的響應者Responder。
以上圖為例,假設點擊了 View3
。
首先調用 UIWindow 的 hitTest:withEvent:
,在這個方法中調用 pointInside:withEvent:
方法判斷點擊是否在 UIWindow 的范圍內,顯然 pointInside
返回 YES
;
然后遍歷 window 的子視圖,來到 RootView,調用 RootView 的 hitTest
和 pointInside
,因為點擊發生在 RootView 中所以繼續遍歷它的子視圖。
從 View2 開始的,調用 View2 的 hitTest
和 pointInside
,由于觸摸點在 View2 的范圍內,所以 pointInside
返回 YES
;然后繼續遍歷 View2 的子視圖,從 View4 開始,因為點擊不發生在 View4, 所以 pointInside
返回 NO
,而 View4 沒有子視圖,所以 hitTest
返回 null
;然后繼續在 View2 的另外一個子視圖 View3 中調用 hitTest
和 pointInside
,因為點擊的就是 View3 所以 pointInside
返回 YES
,且 View3 沒有子視圖,所以 hitTest
返回了自己 View3
;接著 View2
的 hitTest
也返回 View3
,RootView 的 hitTest
也返回 View3,直到 UIWindow 也返回 View3,自此我們找到了響應視圖:View3。
小結:
- 尋找事件的響應視圖是通過調用視圖的
hitTest
和pointInside
完成的。 -
hitTest
的調用順序是從 UIWindow 開始,對視圖的每個子視圖依次調用。 - 遍歷直到找到響應視圖,然后逐級返回最終到 UIWindow 返回此視圖,哪怕還有未遍歷到的視圖,也不會去遍歷了。
三、響應者
所有繼承 UIResponder 的控件都有一個 nextResponder 屬性,此屬性會返回在 Responder Chain 中的下一個事件處理者,如果每個 Responder 都不處理事件,那么事件將會被丟棄。所以繼承自 UIResponder 的子類便會構成一條響應者鏈,所以我們可以打印下以 View3 為開始的響應者鏈是什么樣的:
可以看到響應者鏈一直延伸到 AppDelegate,View3 的下一個是 View2,也就是 View3 的父視圖,View2 下一個是 RootView,也是父視圖,而 RootView 的下一個則是 Controller。
所以下一個響應者的規則是:如果有父視圖,則 nextResponder 指向父視圖,如果是控制器根視圖則指向控制器,控制器如果在導航控制器中,則指向導航控制器的相關顯示視圖,最后指向導航控制器,如果是根控制器則指向 UIWindow,UIWindow 的 nexResponder 指向 UIApplication,最后指向 AppDelegate,而他們實現這一套指向都是靠重寫 nextReponder 實現的。
繼續接著上面的例子,當點擊了 View3,先是由 UIWindow 通過 hitTest
返回所找到響應者 View3;接著會執行 View3 的 touchesBegan
,然后是通過 nextResponder
依次是 View2、RootView,完全是按照 nextResponder
鏈條的調用順序,touchesEnded
也是同樣的順序。
上面是 View3 不處理點擊事件的情況,接下來我們為 View3 添加一個點擊事件處理,看看又會是什么樣的調用過程:
可以看到 touchesBegan
順著 nextResponder
鏈條調用了,但是 View3 處理了事件,去執行了相關是事件處理方法,而 touchesEnded
并沒有得到調用。
小結:
找到最適合的響應視圖后事件會從此視圖開始沿著響應鏈
nextResponder
傳遞,直到找到處理事件的視圖,如果沒有處理的事件會被丟棄。如果視圖有父視圖則
nextResponder
指向父視圖,如果是根視圖則指向控制器,最終指向AppDelegate
, 他們都是通過重寫nextResponder
來實現。
以上是視圖可以正常點擊的情況,但在一些情況下,視圖無法點擊,這時候響應者鏈會如何傳遞呢?
無法點擊是的情況總結:
-
alpha = 0
、子視圖的frame
超出父視圖、userInteractionEnabled = NO
、hidden = YES
這些情況下,視圖會被忽略,不會調用hitTest
。 - 若父視圖被忽略,后其所有子視圖也會被忽略。換句話說,若父視圖不可點擊,則其子視圖一定不能點擊。
四、應用示例
1、點擊透傳
RootView 有 2 個重疊在一起的子視圖 View1 和 View2,View2 覆蓋在 View1 上面,如何做到點擊 View1 觸發 View2 的處理邏輯?
很簡單,設置 View2 的 userInteractionEnabled = NO
即可。
2、限定點擊區域
給定一個顯示為圓形的視圖,實現只有在點擊區域在圓形里面才視為有效。
我們可以重寫View的pointInside方法來判斷點擊的點是否在圓內,也就是判斷點擊的點到圓心的距離是否小于等于半徑就可以。
@implementation CircleView
- (void)awakeFromNib {
[super awakeFromNib];
self.layer.cornerRadius = self.frame.size.width / 2.0f;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
const CGFloat radius = self.frame.size.width / 2.0f;
CGFloat xOffset = point.x - radius;
CGFloat yOffset = point.y - radius;
CGFloat distance = sqrt(xOffset * xOffset + yOffset * yOffset);
return distance <= radius;
}
@end