概述
iOS響應(yīng)者鏈(Responder Chain)是支撐App界面交互的重要基礎(chǔ)饵较,點(diǎn)擊、滑動(dòng)遭赂、旋轉(zhuǎn)循诉、搖晃等都離不開其背后的響應(yīng)者鏈,所以每個(gè)iOS開發(fā)人員都應(yīng)該徹底掌握響應(yīng)者鏈的響應(yīng)邏輯撇他,本文旨在通過demo測試的方式展現(xiàn)響應(yīng)者鏈的具體響應(yīng)過程茄猫,幫助讀者徹底掌握響應(yīng)者鏈。
Demo
你可以在這里(GitHub地址)下載本文測試的Demo源碼困肩,閱讀本文的同時(shí)結(jié)合Demo程序有助于更加直觀深刻的理解划纽。
探究過程
響應(yīng)者(Responder)
當(dāng)我們觸控手機(jī)屏幕時(shí)系統(tǒng)便會(huì)將這一操作封裝成一個(gè)UIEvent放到事件隊(duì)列里面,然后Application從事件隊(duì)列取出這個(gè)事件锌畸,接著需要找到去響應(yīng)這個(gè)事件的最佳視圖也就是Responder, 所以開始的第一步應(yīng)該是找到Responder, 那么又是如何找到的呢勇劣?那就不得不引出UIView的2個(gè)方法:
- -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
返回視圖層級中能響應(yīng)觸控點(diǎn)的最深視圖 - -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
返回視圖是否包含指定的某個(gè)點(diǎn)
通過在顯示視圖層級中依次對視圖調(diào)用這個(gè)2個(gè)方法來確認(rèn)該視圖是不是能響應(yīng)這個(gè)點(diǎn)擊的點(diǎn),首先會(huì)調(diào)用hitTest潭枣,然后hitTest會(huì)調(diào)用pointInside芭毙,最終hitTest返回的那個(gè)view就是最終的響應(yīng)者Responder, 那么問題來了,在視圖層級中是如何確定該對哪個(gè)View調(diào)用呢卸耘?優(yōu)先級又是什么?
為了探尋其中的邏輯粘咖,在Demo中我們構(gòu)建了一個(gè)如下圖所示的多重視圖:
這是一個(gè)簡單的控制器視圖蚣抗,在Controller的視圖上添加了View1-View4共4個(gè)視圖,View1-View4和RootView都繼承自BaseView瓮下, BaseView繼承自UIView; 其中 View1翰铡、View2是RootView的子視圖,View3讽坏、View4是View2的子視圖,他們的繼承關(guān)系和父子關(guān)系圖下圖:
為了能觀測到UIView的hitTest和pointInside調(diào)用過程锭魔,我們寫個(gè)分類通過方法交換來打印調(diào)用的日志:
@implementation UIView (DandJ)
+ (void)load {
Method origin = class_getInstanceMethod([UIView class], @selector(hitTest:withEvent:));
Method custom = class_getInstanceMethod([UIView class], @selector(dandJ_hitTest:withEvent:));
method_exchangeImplementations(origin, custom);
origin = class_getInstanceMethod([UIView class], @selector(pointInside:withEvent:));
custom = class_getInstanceMethod([UIView class], @selector(dandJ_pointInside:withEvent:));
method_exchangeImplementations(origin, custom);
}
- (UIView *)dandJ_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"%@ hitTest", NSStringFromClass([self class]));
UIView *result = [self dandJ_hitTest:point withEvent:event];
NSLog(@"%@ hitTest return: %@", NSStringFromClass([self class]), NSStringFromClass([result class]));
return result;
}
- (BOOL)dandJ_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"%@ pointInside", NSStringFromClass([self class]));
BOOL result = [self dandJ_pointInside:point withEvent:event];
NSLog(@"%@ pointInside return: %@", NSStringFromClass([self class]), result ? @"YES":@"NO");
return result;
}
@end
當(dāng)我們點(diǎn)擊視圖中的View3(紫色)時(shí)看看日志輸出:
從日志中我們可以看到,首先是從UIWindow開始調(diào)用hitTest, 然后經(jīng)過一段導(dǎo)航控制器的視圖路呜,因?yàn)槲覀兊目刂破魇窃趯?dǎo)航控制的迷捧,所以可以先忽略這一段织咧,然后來到RootView,調(diào)用RootView的hitTest和pointInside,因?yàn)辄c(diǎn)擊發(fā)生在RootView中所以繼續(xù)遍歷它的子視圖,可以看到是從View2開始的漠秋,調(diào)用View2的hitTest和pointInside笙蒙,pointInside返回YES,然后繼續(xù)遍歷View2的子視圖庆锦,從View4開始捅位,因?yàn)辄c(diǎn)擊不發(fā)生在View4所以pointInside返回NO,而View4沒有子視圖了,所以返回了nil也就是打印出來的null,然后繼續(xù)在View2的另外一個(gè)子視圖View3(目標(biāo)視圖)中調(diào)用hitTest和pointInside搂抒,因?yàn)槲覀凕c(diǎn)擊的就是View3所以pointInside返回YES,且View3沒有子視圖所以hitTest返回了自己View3,接著View2的hitTest也返回View3直到UIWindow返回View3, 自此我們找到了響應(yīng)視圖:View3艇搀!另外我們看到對其他的Window也有調(diào)用,只不過返回了nil求晶。
- 結(jié)論:
- 尋找事件的最佳響應(yīng)視圖是通過對視圖調(diào)用hitTest和pointInside完成的
- hitTest的調(diào)用順序是從UIWindow開始焰雕,對視圖的每個(gè)子視圖依次調(diào)用,子視圖的調(diào)用順序是從后面往前面誉帅,也可以說是從顯示最上面到最下面
- 遍歷直到找到響應(yīng)視圖淀散,然后逐級返回最終到UIWindow返回此視圖
PS:
1.關(guān)于最后一個(gè)能響應(yīng)的子視圖demo中是因?yàn)闆]有子視圖而確定的,這不是唯一確定的條件,因?yàn)橛行┣闆r下視圖可能會(huì)被忽略蚜锨,不會(huì)調(diào)用hitTest档插,這與userInteractionEnabled, alpha, frame等有關(guān),在下個(gè)demo會(huì)演示亚再。
2.與加速度器郭膛、陀螺儀、磁力儀相關(guān)的運(yùn)動(dòng)事件不遵循此響應(yīng)鏈氛悬,他們是由Core Motion 直接派發(fā)的
處理者
在上面我們已經(jīng)找到了點(diǎn)擊事件的響應(yīng)者View3,但是我們并未給View3添加相應(yīng)的點(diǎn)擊處理邏輯(UITapGestureRecognizer)则剃,所以View3并不會(huì)處理事件,那么View3不處理由會(huì)交給誰處理呢如捅?如果View3處理了又是怎么樣的呢棍现?
能夠處理UI事件都是繼承UIResponder的子類對象,UIResponder主要有以下4個(gè)方法來處理事件:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
分別是對應(yīng)從觸摸事件的開始镜遣、移動(dòng)己肮、結(jié)束、取消悲关,如果你想自定義響應(yīng)事件可以重寫這幾個(gè)方法來實(shí)現(xiàn)谎僻。如果某個(gè)Responder沒處理事件,事件會(huì)被傳遞寓辱,UIResponder都有一個(gè)nextResponder屬性艘绍,此屬性會(huì)返回在Responder Chain中的下一個(gè)事件處理者,如果每個(gè)Responder都不處理事件秫筏,那么事件將會(huì)被丟棄诱鞠。所以繼承自UIResponder的子類便會(huì)構(gòu)成一條響應(yīng)者鏈挎挖,所以我們可以打印下以View3為開始的響應(yīng)者鏈?zhǔn)鞘裁礃拥模?/p>
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
UIResponder *nextResponder = self.view3.nextResponder;
NSMutableString *pre = [NSMutableString stringWithString:@"--"];
NSLog(@"View3");
while (nextResponder) {
NSLog(@"%@%@", pre, NSStringFromClass([nextResponder class]));
[pre appendString:@"--"];
nextResponder = nextResponder.nextResponder;
}
}
可以看到響應(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ū)Ш娇刂破鞣蟠妫绻歉刂破鲃t指向UIWindow,UIWindow的nexResponder指向UIApplication最后指向AppDelegate,而他們實(shí)現(xiàn)這一套指向都是靠重寫nextReponder實(shí)現(xiàn)的墓造。
為了驗(yàn)證點(diǎn)擊上面的事件的處理順序,我們繼續(xù)上面那個(gè)demo,為RootView和View1-View4的基類BaseView重寫這幾個(gè)方法:
@implementation BaseView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesBegan", NSStringFromClass([self class]));
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesMoved", NSStringFromClass([self class]));
[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesEnded", NSStringFromClass([self class]));
[super touchesEnded:touches withEvent:event];
}
@end
同樣也為控制器(FindResponderController)添加相關(guān)touches方法锚烦,日志打印看調(diào)用順序:
可以看到先是由UIWindow通過hitTest返回所找到的最合適的響應(yīng)者View3, 接著執(zhí)行了View3的touchesBegan觅闽,然后是通過nextResponder依次是View2、RootView涮俄、FindResponderController,可以看到完全是按照nextResponder鏈條的調(diào)用順序蛉拙,touchesEnded也是同樣的順序。
PS:感興趣的可以繼續(xù)重寫AppDelegate的相關(guān)touches方法彻亲,驗(yàn)證最終是不是會(huì)被順序調(diào)用孕锄。
上面是View3不處理點(diǎn)擊事件的情況,接下來我們?yōu)閂iew3添加一個(gè)點(diǎn)擊事件處理苞尝,看看又會(huì)是什么樣的調(diào)用過程:
@implementation View3
- (void)awakeFromNib {
[super awakeFromNib];
[self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAction:)]];
}
- (void)tapAction:(UITapGestureRecognizer *)recognizer {
NSLog(@"View3 taped");
}
@end
運(yùn)行程序畸肆,點(diǎn)擊View3看看日志打印:
可以看到touchesBegan順著nextResponder鏈條調(diào)用了宙址,但是View3處理了事件轴脐,去執(zhí)行了相關(guān)是事件處理方法,而touchesEnded并沒有得到調(diào)用抡砂。
總結(jié)
1.找到最適合的響應(yīng)視圖后事件會(huì)從此視圖開始沿著響應(yīng)鏈nextResponder傳遞大咱,直到找到處理事件的視圖,如果沒有處理的事件會(huì)被丟棄。
2.如果視圖有父視圖則nextResponder指向父視圖注益,如果是根視圖則指向控制器碴巾,最終指向AppDelegate, 他們都是通過重寫nextResponder來實(shí)現(xiàn)。
無法響應(yīng)的情況
在[響應(yīng)者]章節(jié)我們已經(jīng)提到尋找最佳響應(yīng)者是通過hitTest函數(shù)調(diào)用完成的丑搔,那么存在哪些情況下視圖會(huì)被忽視厦瓢,而不被調(diào)用hiTest呢?
下面我么也通過第2個(gè)demo來演示低匙,在什么情況下hitTest不會(huì)被調(diào)用或者返回nil,在demo中從上到下我們分別模擬了Alpha=0、子視圖超出父視圖的情況碳锈、userInteractionEnabled=NO顽冶、hidden=YES這4中情況:
結(jié)論
1.Alpha=0、子視圖超出父視圖的情況售碳、userInteractionEnabled=NO强重、hidden=YES視圖會(huì)被忽略绞呈,不會(huì)調(diào)用hitTest
2.父視圖被忽略后其所有子視圖也會(huì)被忽略,所以View3上的button不會(huì)有點(diǎn)擊反應(yīng)
3.出現(xiàn)視圖無法響應(yīng)的情況间景,可以考慮上訴情況來排查問題
應(yīng)用示例
點(diǎn)擊透傳
RootView有2個(gè)重疊在一起的子視圖View1和View2, View2覆蓋在View1上面佃声,如何做到點(diǎn)擊View1觸發(fā)View2的處理邏輯?
很簡單倘要,設(shè)置View2的userInteractionEnabled=NO即可圾亏。
限定點(diǎn)擊區(qū)域
給定一個(gè)顯示為圓形的視圖,實(shí)現(xiàn)只有在點(diǎn)擊區(qū)域在圓形里面才視為有效封拧。
我們可以重寫View的pointInside方法來判斷點(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
個(gè)人理解與總結(jié)
1泽西、概述
首先曹铃,當(dāng)發(fā)生事件響應(yīng)時(shí),必須知道由誰來響應(yīng)事件捧杉。在IOS中陕见,由響應(yīng)者鏈來對事件進(jìn)行響應(yīng),所有事件響應(yīng)的類都是UIResponder的子類味抖,響應(yīng)者鏈?zhǔn)且粋€(gè)由不同對象組成的層次結(jié)構(gòu)评甜,其中的每個(gè)對象將依次獲得響應(yīng)事件消息的機(jī)會(huì)。當(dāng)發(fā)生事件時(shí)非竿,事件首先被發(fā)送給第一響應(yīng)者蜕着,第一響應(yīng)者往往是事件發(fā)生的視圖,也就是用戶觸摸屏幕的地方红柱。事件將沿著響應(yīng)者鏈一直向下傳遞承匣,直到被接受并做出處理。一般來說锤悄,第一響應(yīng)者是個(gè)視圖對象或者其子類對象韧骗,當(dāng)其被觸摸后事件被交由它處理,如果它不處理零聚,事件就會(huì)被傳遞給它的視圖控制器對象viewcontroller(如果存在)袍暴,然后是它的父視圖(superview)對象(如果存在),以此類推隶症,直到頂層視圖政模。接下來會(huì)沿著頂層視圖(top view)到窗口(UIWindow對象)再到程序(UIApplication對象)。如果整個(gè)過程都沒有響應(yīng)這個(gè)事件蚂会,該事件就被丟棄淋样。一般情況下,在響應(yīng)者鏈中只要由對象處理事件胁住,事件就停止傳遞趁猴。
2刊咳、響應(yīng)者鏈(Responder Chain)
響應(yīng)者鏈有以下特點(diǎn):
1、響應(yīng)者鏈通常是由視圖(UIView)構(gòu)成的儡司;
2娱挨、一個(gè)視圖的下一個(gè)響應(yīng)者是它視圖控制器(UIViewController)(如果有的話),然后再轉(zhuǎn)給它的父視圖(Super View)捕犬;
3跷坝、視圖控制器(如果有的話)的下一個(gè)響應(yīng)者為其管理的視圖的父視圖;
4或听、單例的窗口(UIWindow)的內(nèi)容視圖將指向窗口本身作為它的下一個(gè)響應(yīng)者
需要指出的是探孝,Cocoa Touch應(yīng)用不像Cocoa應(yīng)用,它只有一個(gè)UIWindow對象誉裆,因此整個(gè)響應(yīng)者鏈要簡單一點(diǎn)顿颅;
5、單例的應(yīng)用(UIApplication)是一個(gè)響應(yīng)者鏈的終點(diǎn)足丢,它的下一個(gè)響應(yīng)者指向nil粱腻,以結(jié)束整個(gè)循環(huán)。
3斩跌、事件分發(fā)(Event Delivery)
第一響應(yīng)者(First responder)指的是當(dāng)前接受觸摸的響應(yīng)者對象(通常是一個(gè)UIView對象)绍些,即表示當(dāng)前該對象正在與用戶交互,它是響應(yīng)者鏈的開端耀鸦。整個(gè)響應(yīng)者鏈和事件分發(fā)的使命都是找出第一響應(yīng)者柬批。
UIWindow對象以消息的形式將事件發(fā)送給第一響應(yīng)者,使其有機(jī)會(huì)首先處理事件袖订。如果第一響應(yīng)者沒有進(jìn)行處理氮帐,系統(tǒng)就將事件(通過消息)傳遞給響應(yīng)者鏈中的下一個(gè)響應(yīng)者,看看它是否可以進(jìn)行處理洛姑。
iOS系統(tǒng)檢測到手指觸摸(Touch)操作時(shí)會(huì)將其打包成一個(gè)UIEvent對象上沐,并放入當(dāng)前活動(dòng)Application的事件隊(duì)列,單例的UIApplication會(huì)從事件隊(duì)列中取出觸摸事件并傳遞給單例的UIWindow來處理楞艾,UIWindow對象首先會(huì)使用hitTest:withEvent:方法尋找此次Touch操作初始點(diǎn)所在的視圖(View)参咙,即需要將觸摸事件傳遞給其處理的視圖,這個(gè)過程稱之為hit-test view硫眯。
UIWindow實(shí)例對象會(huì)首先在它的內(nèi)容視圖上調(diào)用hitTest:withEvent:蕴侧,此方法會(huì)在其視圖層級結(jié)構(gòu)中的每個(gè)視圖上調(diào)用pointInside:withEvent:(該方法用來判斷點(diǎn)擊事件發(fā)生的位置是否處于當(dāng)前視圖范圍內(nèi),以確定用戶是不是點(diǎn)擊了當(dāng)前視圖)两入,如果pointInside:withEvent:返回YES净宵,則繼續(xù)逐級調(diào)用,直到找到touch操作發(fā)生的位置,這個(gè)視圖也就是要找的hit-test view塘娶。
hitTest:withEvent:方法的處理流程如下:
首先調(diào)用當(dāng)前視圖的pointInside:withEvent:方法判斷觸摸點(diǎn)是否在當(dāng)前視圖內(nèi);
若返回NO,則hitTest:withEvent:返回nil;
若返回YES,則向當(dāng)前視圖的所有子視圖(subviews)發(fā)送hitTest:withEvent:消息痊夭,所有子視圖的遍歷順序是從最頂層視圖一直到到最底層視圖刁岸,即從subviews數(shù)組的末尾向前遍歷,直到有子視圖返回非空對象或者全部子視圖遍歷完畢她我;
若第一次有子視圖返回非空對象虹曙,則hitTest:withEvent:方法返回此對象,處理結(jié)束番舆;
如所有子視圖都返回非酝碳,則hitTest:withEvent:方法返回自身(self)。
4恨狈、說明
1疏哗、響應(yīng)者鏈的傳遞順序是從子類逐級到父類的傳遞方向,事件分發(fā)的順序是從父類逐級到子類的順序禾怠。
2返奉、如果最終hit-test沒有找到第一響應(yīng)者,或者第一響應(yīng)者沒有處理該事件吗氏,則該事件會(huì)沿著響應(yīng)者鏈向上回溯芽偏,如果UIWindow實(shí)例和UIApplication實(shí)例都不能處理該事件,則該事件會(huì)被丟棄弦讽;
3污尉、hitTest:withEvent:方法將會(huì)忽略隱藏(hidden=YES)的視圖,禁止用戶操作(userInteractionEnabled=YES)的視圖往产,以及alpha級別小于0.01(alpha<0.01)的視圖被碗。如果一個(gè)子視圖的區(qū)域超過父視圖的bound區(qū)域(父視圖的clipsToBounds 屬性為NO,這樣超過父視圖bound區(qū)域的子視圖內(nèi)容也會(huì)顯示)捂齐,那么正常情況下對子視圖在父視圖之外區(qū)域的觸摸操作不會(huì)被識(shí)別,因?yàn)楦敢晥D的pointInside:withEvent:方法會(huì)返回NO,這樣就不會(huì)繼續(xù)向下遍歷子視圖了蛮放。當(dāng)然,也可以重寫pointInside:withEvent:方法來處理這種情況奠宜。
4包颁、我們可以重寫hitTest:withEvent:來達(dá)到某些特定的目的,實(shí)際應(yīng)用中很少用到這些压真。
以上內(nèi)容參考作者原文地址:原文地址