一、概述
iOS 響應(yīng)者鏈(Responder Chain)是支撐 App 界面交互的重要基礎(chǔ),點(diǎn)擊、滑動(dòng)、旋轉(zhuǎn)、搖晃等都離不開(kāi)其背后的響應(yīng)者鏈鏈。
簡(jiǎn)單的說(shuō)(雖然不準(zhǔn)確),響應(yīng)者鏈的作用就是讓 APP 知道用戶點(diǎn)擊里了哪里,然后應(yīng)該哪個(gè)控件做出反應(yīng)。專(zhuān)業(yè)點(diǎn)說(shuō),響應(yīng)者鏈就是由多個(gè)響應(yīng)者組合起來(lái)的鏈條,就叫做響應(yīng)者鏈。它表示了每個(gè)響應(yīng)者之間的聯(lián)系,并且可以使得一個(gè)事件可選擇多個(gè)對(duì)象處理。
二、事件的產(chǎn)生和傳遞
當(dāng)一個(gè)觸摸事件產(chǎn)生的時(shí)候,程序是如何找到第一響應(yīng)者的呢?也就是說(shuō)程序怎么知道點(diǎn)擊了哪個(gè)控件呢?

當(dāng)點(diǎn)擊了屏幕會(huì)產(chǎn)生一個(gè)觸摸事件,消息循環(huán)(runloop)會(huì)接收到觸摸事件放到消息隊(duì)列里,UIApplication 會(huì)從消息隊(duì)列里取事件分發(fā)下去,接著需要找到去響應(yīng)這個(gè)事件的最佳視圖,也就是 Responder,所以開(kāi)始的第一步應(yīng)該是找到 Responder,那么又是如何找到的呢?那就不得不引出 UIView 的 2 個(gè)方法:
// 返回此次觸摸事件初始點(diǎn)所在的視圖
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
// 返回視圖是否包含指定的某個(gè)點(diǎn)
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
通過(guò)在顯示視圖層級(jí)中依次對(duì)視圖調(diào)用這個(gè) 2 個(gè)方法來(lái)確認(rèn)該視圖是不是能響應(yīng)這個(gè)點(diǎn)擊的點(diǎn),首先會(huì)調(diào)用 hitTest,然后在 hitTest 中調(diào)用 pointInside,最終 hitTest 返回的那個(gè) view 就是最終的響應(yīng)者Responder。

以上圖為例,假設(shè)點(diǎn)擊了 View3。
首先調(diào)用 UIWindow 的 hitTest:withEvent:,在這個(gè)方法中調(diào)用 pointInside:withEvent: 方法判斷點(diǎn)擊是否在 UIWindow 的范圍內(nèi),顯然 pointInside 返回 YES;
然后遍歷 window 的子視圖,來(lái)到 RootView,調(diào)用 RootView 的 hitTest 和 pointInside,因?yàn)辄c(diǎn)擊發(fā)生在 RootView 中所以繼續(xù)遍歷它的子視圖。
從 View2 開(kāi)始的,調(diào)用 View2 的 hitTest 和 pointInside,由于觸摸點(diǎn)在 View2 的范圍內(nèi),所以 pointInside 返回 YES;然后繼續(xù)遍歷 View2 的子視圖,從 View4 開(kāi)始,因?yàn)辄c(diǎn)擊不發(fā)生在 View4, 所以 pointInside 返回 NO,而 View4 沒(méi)有子視圖,所以 hitTest 返回 null;然后繼續(xù)在 View2 的另外一個(gè)子視圖 View3 中調(diào)用 hitTest 和 pointInside,因?yàn)辄c(diǎn)擊的就是 View3 所以 pointInside 返回 YES,且 View3 沒(méi)有子視圖,所以 hitTest 返回了自己 View3;接著 View2 的 hitTest 也返回 View3,RootView 的 hitTest 也返回 View3,直到 UIWindow 也返回 View3,自此我們找到了響應(yīng)視圖:View3。

小結(jié):
- 尋找事件的響應(yīng)視圖是通過(guò)調(diào)用視圖的
hitTest和pointInside完成的。 -
hitTest的調(diào)用順序是從 UIWindow 開(kāi)始,對(duì)視圖的每個(gè)子視圖依次調(diào)用。 - 遍歷直到找到響應(yīng)視圖,然后逐級(jí)返回最終到 UIWindow 返回此視圖,哪怕還有未遍歷到的視圖,也不會(huì)去遍歷了。
三、響應(yīng)者
所有繼承 UIResponder 的控件都有一個(gè) nextResponder 屬性,此屬性會(huì)返回在 Responder Chain 中的下一個(gè)事件處理者,如果每個(gè) Responder 都不處理事件,那么事件將會(huì)被丟棄。所以繼承自 UIResponder 的子類(lèi)便會(huì)構(gòu)成一條響應(yīng)者鏈,所以我們可以打印下以 View3 為開(kāi)始的響應(yīng)者鏈?zhǔn)鞘裁礃拥模?/p>

可以看到響應(yīng)者鏈一直延伸到 AppDelegate,View3 的下一個(gè)是 View2,也就是 View3 的父視圖,View2 下一個(gè)是 RootView,也是父視圖,而 RootView 的下一個(gè)則是 Controller。
所以下一個(gè)響應(yīng)者的規(guī)則是:如果有父視圖,則 nextResponder 指向父視圖,如果是控制器根視圖則指向控制器,控制器如果在導(dǎo)航控制器中,則指向?qū)Ш娇刂破鞯南嚓P(guān)顯示視圖,最后指向?qū)Ш娇刂破?,如果是根控制器則指向 UIWindow,UIWindow 的 nexResponder 指向 UIApplication,最后指向 AppDelegate,而他們實(shí)現(xiàn)這一套指向都是靠重寫(xiě) nextReponder 實(shí)現(xiàn)的。
繼續(xù)接著上面的例子,當(dāng)點(diǎn)擊了 View3,先是由 UIWindow 通過(guò) hitTest 返回所找到響應(yīng)者 View3;接著會(huì)執(zhí)行 View3 的 touchesBegan,然后是通過(guò) nextResponder 依次是 View2、RootView,完全是按照 nextResponder 鏈條的調(diào)用順序,touchesEnded 也是同樣的順序。

上面是 View3 不處理點(diǎn)擊事件的情況,接下來(lái)我們?yōu)?View3 添加一個(gè)點(diǎn)擊事件處理,看看又會(huì)是什么樣的調(diào)用過(guò)程:

可以看到 touchesBegan 順著 nextResponder 鏈條調(diào)用了,但是 View3 處理了事件,去執(zhí)行了相關(guān)是事件處理方法,而 touchesEnded 并沒(méi)有得到調(diào)用。
小結(jié):
找到最適合的響應(yīng)視圖后事件會(huì)從此視圖開(kāi)始沿著響應(yīng)鏈
nextResponder傳遞,直到找到處理事件的視圖,如果沒(méi)有處理的事件會(huì)被丟棄。如果視圖有父視圖則
nextResponder指向父視圖,如果是根視圖則指向控制器,最終指向AppDelegate, 他們都是通過(guò)重寫(xiě)nextResponder來(lái)實(shí)現(xiàn)。

以上是視圖可以正常點(diǎn)擊的情況,但在一些情況下,視圖無(wú)法點(diǎn)擊,這時(shí)候響應(yīng)者鏈會(huì)如何傳遞呢?
無(wú)法點(diǎn)擊是的情況總結(jié):
-
alpha = 0、子視圖的frame超出父視圖、userInteractionEnabled = NO、hidden = YES這些情況下,視圖會(huì)被忽略,不會(huì)調(diào)用hitTest。 - 若父視圖被忽略,后其所有子視圖也會(huì)被忽略。換句話說(shuō),若父視圖不可點(diǎn)擊,則其子視圖一定不能點(diǎn)擊。
四、應(yīng)用示例
1、點(diǎn)擊透?jìng)?/p>
RootView 有 2 個(gè)重疊在一起的子視圖 View1 和 View2,View2 覆蓋在 View1 上面,如何做到點(diǎn)擊 View1 觸發(fā) View2 的處理邏輯?
很簡(jiǎn)單,設(shè)置 View2 的 userInteractionEnabled = NO 即可。
2、限定點(diǎn)擊區(qū)域
給定一個(gè)顯示為圓形的視圖,實(shí)現(xiàn)只有在點(diǎn)擊區(qū)域在圓形里面才視為有效。
我們可以重寫(xiě)View的pointInside方法來(lái)判斷點(diǎn)擊的點(diǎn)是否在圓內(nèi),也就是判斷點(diǎn)擊的點(diǎn)到圓心的距離是否小于等于半徑就可以。
@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