觸摸事件的生命周期
當(dāng)我們手指觸碰屏幕的那一刻进统,一個觸摸事件便產(chǎn)生了。經(jīng)過進(jìn)程間通信猴蹂,觸摸事件被傳遞到合適的應(yīng)用之中,在該應(yīng)用內(nèi)部觸摸事件歷經(jīng)坎坷楣嘁,最終被釋放掉磅轻。
整個過程如下圖所示:
如上圖所示珍逸,觸摸事件分為兩大階段:
系統(tǒng)響應(yīng)階段
- 手指觸摸屏幕,屏幕感應(yīng)到觸碰后聋溜,將事件交給IOKit處理
- IOKit將觸摸事件封裝成一個IOHIDEvent對象谆膳,并通過mach port傳遞給SpringBord進(jìn)程
mach port是各個進(jìn)程的端口,各進(jìn)程通過它來進(jìn)行進(jìn)程間通信
SpringBord是一個系統(tǒng)進(jìn)程撮躁,可以理解為桌面系統(tǒng)漱病。它用來統(tǒng)一管理系統(tǒng)接收到的觸摸事件
- SpringBoard進(jìn)程因接收到觸摸事件,觸發(fā)了其主線程runloop的source1事件源的回調(diào)把曼。
此時SpringBoard會根據(jù)當(dāng)前桌面的狀態(tài)杨帽,判斷應(yīng)該由誰處理此次觸摸事件。因為事件發(fā)生時嗤军,你可能正在桌面上翻頁注盈,也可能正在刷微博。若是前者(即前臺無APP運行)叙赚,則觸發(fā)SpringBoard本身主線程runloop的source0事件源的回調(diào)老客,將事件交由桌面系統(tǒng)去消耗;若是后者(即有app正在前臺運行)震叮,則將觸摸事件通過IPC傳遞給前臺APP進(jìn)程胧砰,接下來的事情便是APP內(nèi)部對于觸摸事件的響應(yīng)了。
app響應(yīng)階段
- APP進(jìn)程的mach port接受到SpringBoard進(jìn)程傳遞來的觸摸事件冤荆,主線程的runloop被喚醒朴则,觸發(fā)了source1回調(diào)
- source1回調(diào)又觸發(fā)了一個source0回調(diào),將接收到的IOHIDEvent對象封裝成UIEvent對象钓简,此時APP將正式開始對于觸摸事件的響應(yīng)
- source0回調(diào)內(nèi)部將觸摸事件添加到UIApplication對象的事件隊列中。事件出隊后汹想,UIApplication開始一個尋找最佳響應(yīng)者的過程外邓,這個過程又稱hit-testing
- 尋找到最佳響應(yīng)者后,接下來的事情便是事件在響應(yīng)鏈中的傳遞及響應(yīng)古掏。事實上损话,事件除了被響應(yīng)者消耗,還能被手勢識別器或是target-action模式捕捉并消耗掉槽唾,其中涉及到事件響應(yīng)的優(yōu)先級問題
- 觸摸事件歷經(jīng)坎坷丧枪,要么被某個響應(yīng)對象捕獲后釋放,要么最終也沒能找到能夠響應(yīng)的對象庞萍,然后釋放
觸摸對象UITouch拧烦、事件UIEvent、響應(yīng)者UIResponder
UITouch
簡單理解钝计,一根手指對應(yīng)一個UITouch對象
- 更準(zhǔn)確一點是恋博,一根手指觸摸一次屏幕齐佳,就產(chǎn)生一個UITouch對象。多根手指同時觸摸屏幕债沮,會產(chǎn)生多個UITouch對象
- 多個手指先后觸摸,系統(tǒng)會根據(jù)觸摸的位置判斷是否更新同一個UITouch對象疫衩。若兩個手指一前一后觸摸同一個位置(即雙擊)硅蹦,那么第一次觸摸時生成一個UITouch對象,第二次觸摸更新這個UITouch對象(UITouch對象的 tap count 屬性值從1變成2)闷煤;若兩個手指一前一后觸摸的位置不同提针,將會生成兩個UITouch對象,兩者之間沒有聯(lián)系曹傀。
- 每個UITouch對象記錄了觸摸的一些信息辐脖,包括觸摸時間、位置皆愉、階段嗜价、所處的視圖、窗口等信息
UIEvent
觸摸事件
- 觸摸的目的是生成觸摸事件供響應(yīng)者響應(yīng)幕庐,一個觸摸事件對應(yīng)一個UIEvent對象久锥,其中的 type 屬性標(biāo)識了事件的類型(之前說過事件不只是觸摸事件)。
- UIEvent對象中包含了觸發(fā)該事件的觸摸對象的集合异剥,因為一個觸摸事件可能是由多個手指同時觸摸產(chǎn)生的瑟由。觸摸對象集合通過 allTouches 屬性獲取。
UIResponder
每個響應(yīng)者都是一個UIResponder對象冤寿,所有派生自UIResponder的對象歹苦,都具有響應(yīng)事件的能力:
- UIApplication
- UIViewController
- UIView(包含UIWindow)
- AppDelegate
響應(yīng)者之所以能夠響應(yīng)事件,是因為UIResponder提供了四個響應(yīng)事件的方法:
- (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)者對象接收到事件的時候會被調(diào)用督怜,方法內(nèi)部用來做出響應(yīng)殴瘦。默認(rèn)的實現(xiàn)是不做處理,并將事件傳遞給響應(yīng)鏈中的上一個結(jié)點号杠。
尋找最佳響應(yīng)者(Hit-Testing)
觸摸事件產(chǎn)生后蚪腋,會經(jīng)歷一個尋找最佳響應(yīng)者的過程,目的是找到一個具備最高優(yōu)先級響應(yīng)權(quán)的響應(yīng)對象姨蟋,這個過程叫做Hit-Testing屉凯,那個命中的最佳響應(yīng)者稱為hit-tested view。
事件自上而下的傳遞
如文章開頭的圖所示眼溶,觸摸事件被添加到UIApplication的事件隊列中等待處理悠砚,出隊后,應(yīng)用首先將該事件交給自己的主窗口(當(dāng)前應(yīng)用最后顯示的窗口)偷仿,詢問其能否響應(yīng)事件哩簿。若窗口能響應(yīng)事件宵蕉,則傳遞給子視圖詢問是否能響應(yīng),子視圖若能響應(yīng)則繼續(xù)詢問子視圖节榜。子視圖詢問的順序是優(yōu)先詢問后添加的子視圖羡玛,即子視圖數(shù)組中靠后的視圖(因為后添加的視圖在層級的上面,可以減少遍歷次數(shù))宗苍。整體過程如下所示:
UIApplication ——> UIWindow ——> 子視圖 ——> ... ——> 子視圖
Hit-Testing本質(zhì)
視圖如何判斷能否響應(yīng)事件稼稿?以及視圖如何將事件傳遞給子視圖呢?
首先要知道的是讳窟,以下幾種狀態(tài)的視圖無法響應(yīng)事件:
- 不允許交互:userInteractionEnabled = NO
- 隱藏:hidden = YES 如果父視圖隱藏让歼,那么子視圖也會隱藏,隱藏的視圖無法接收事件
- 透明度:alpha < 0.01 如果設(shè)置一個視圖的透明度<0.01丽啡,會直接影響子視圖的透明度谋右。alpha:0.0~0.01為透明。
每個UIView對象都有一個方法hitTest: withEvent: 补箍,這個方法是Hit-Testing過程中最核心的存在改执,其作用是返回觸摸事件的最佳響應(yīng)者,同時又作為事件傳遞的橋梁坑雅。
該方法返回一個UIView對象辈挂,默認(rèn)實現(xiàn)為:
- 若當(dāng)前視圖無法響應(yīng)事件,則返回nil
- 若當(dāng)前視圖可以響應(yīng)事件裹粤,但無子視圖可以響應(yīng)事件终蒂,則返回自身作為當(dāng)前視圖層次中的事件響應(yīng)者
- 若當(dāng)前視圖可以響應(yīng)事件,同時有子視圖可以響應(yīng)遥诉,則返回子視圖層次中的事件響應(yīng)者
一開始UIApplication將事件UIEvent作為參數(shù)傳遞給UIWindow的 hitTest:withEvent: 方法拇泣,UIWindow的 hitTest:withEvent: 方法在執(zhí)行時若判斷本身能響應(yīng)事件,則調(diào)用子視圖的 hitTest:withEvent: 將事件傳遞給子視圖并詢問子視圖上的最佳響應(yīng)者突那。最終UIWindow返回一個視圖層次中的響應(yīng)者視圖給UIApplication挫酿,這個視圖就是hit-testing的最佳響應(yīng)者。
注意理解愕难,這里最佳響應(yīng)者還是返回給了UIApplication的,整個流程就是事件先從UIApplication分發(fā)下來惫霸,得到最佳響應(yīng)者之后又將該響應(yīng)者返回給UIApplication猫缭。
根據(jù)上面的結(jié)論,hitTest:withEvent:方法實現(xiàn)如下
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
//3種狀態(tài)無法響應(yīng)事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
//觸摸點若不在當(dāng)前視圖上則無法響應(yīng)事件
if ([self pointInside:point withEvent:event] == NO) return nil;
//從后往前遍歷子視圖數(shù)組
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--)
{
// 獲取子視圖
UIView *childView = self.subviews[i];
// 坐標(biāo)系的轉(zhuǎn)換,把觸摸點在當(dāng)前視圖上坐標(biāo)轉(zhuǎn)換為在子視圖上的坐標(biāo)
CGPoint childP = [self convertPoint:point toView:childView];
//詢問子視圖層級中的最佳響應(yīng)視圖
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView)
{
//如果子視圖中有更合適的就返回
return fitView;
}
}
//沒有在子視圖中找到更合適的響應(yīng)視圖壹店,那么自身就是最合適的
return self;
}
值得注意的是 pointInside:withEvent: 這個方法猜丹,用于判斷觸摸點是否在自身坐標(biāo)范圍內(nèi)。默認(rèn)實現(xiàn)是若在坐標(biāo)范圍內(nèi)則返回YES硅卢,否則返回NO射窒。
我們可以修改這個方法來完成一些特殊的需求藏杖,如下:
中間的原型按鈕是底部Tabbar上的控件,而Tabbar是添加在控制器根視圖中的脉顿。默認(rèn)情況下我們點擊圖中紅色方框中按鈕的區(qū)域蝌麸,會發(fā)現(xiàn)按鈕并不會得到響應(yīng)。
上圖對應(yīng)的視圖層級結(jié)構(gòu)大致為:
RootView
└── TableView
└── TabBar
└── CircleButton
點擊紅色方框區(qū)域后艾疟,生成的觸摸事件首先傳到UIWindow来吩,然后傳到控制器的根視圖即RootView。RootView經(jīng)判斷可以響應(yīng)觸摸事件蔽莱,而后將事件傳給了子控件TabBar弟疆。問題就出在這里,因為觸摸點不在TabBar的坐標(biāo)范圍內(nèi)盗冷,因此TabBar無法響應(yīng)該觸摸事件怠苔,hitTest:withEvent: 直接返回了nil。而后RootView就會詢問TableView是否能夠響應(yīng)仪糖,事實上是可以的柑司,因此事件最終被TableView消耗。整個過程乓诽,事件根本沒有傳遞到圓形按鈕帜羊。
根據(jù)hitTest:withEvent:方法內(nèi)部的實現(xiàn),事件傳遞到TabBar時鸠天,TabBar的 hitTest:withEvent: 被調(diào)用讼育,但是 pointInside:withEvent: 會返回NO,如此一來 hitTest:withEvent: 返回了nil稠集。既然如此奶段,可以重寫TabBard的 pointInside:withEvent: ,判斷當(dāng)前觸摸坐標(biāo)是否在子視圖CircleButton的坐標(biāo)范圍內(nèi)剥纷,若在痹籍,則返回YES,反之返回NO晦鞋。這樣一來點擊紅色區(qū)域蹲缠,事件最終會傳遞到CircleButton,CircleButton能夠響應(yīng)事件悠垛,最終事件就由CircleButton響應(yīng)了线定。同時點擊紅色方框以外的非TabBar區(qū)域的情況下,因為TabBar無法響應(yīng)事件确买,會按照預(yù)期由TableView響應(yīng)斤讥。代碼如下:
//TabBar中重寫pointInside方法
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
//將觸摸點坐標(biāo)轉(zhuǎn)換到在CircleButton上的坐標(biāo)
CGPoint pointTemp = [self convertPoint:point toView:_CircleButton];
//若觸摸點在CricleButton上則返回YES
if ([_CircleButton pointInside:pointTemp withEvent:event]) {
return YES;
}
//否則返回默認(rèn)的操作
return [super pointInside:point withEvent:event];
}
事件的響應(yīng)及在響應(yīng)鏈中的傳遞
經(jīng)歷Hit-Testing后,UIApplication已經(jīng)知道事件的最佳響應(yīng)者是誰了湾趾,接下來要做的事情就是:
- 將事件傳遞給最佳響應(yīng)者響應(yīng)
- 事件沿著響應(yīng)鏈傳遞
事件響應(yīng)之前
因為最佳響應(yīng)者具有最高的事件響應(yīng)優(yōu)先級芭商,因此UIApplication會先將事件傳遞給它供其響應(yīng)派草。首先,UIApplication將事件通過 sendEvent: 傳遞給事件所屬的window铛楣,window同樣通過 sendEvent: 再將事件傳遞給hit-tested view近迁,即最佳響應(yīng)者。過程如下:
UIApplication ——> UIWindow ——> hit-tested view
假設(shè)視圖結(jié)構(gòu)為:
rootView
└── redView
└── blueView
在blueView的touchBegan方法打斷點蛉艾,點擊blueView后函數(shù)調(diào)用棧如下:
那么問題又來了钳踊。這個過程中,假如應(yīng)用中存在多個window對象勿侯,UIApplication是怎么知道要把事件傳給哪個window的拓瞪?window又是怎么知道哪個視圖才是最佳響應(yīng)者的呢?
這兩個過程都是傳遞事件的過程助琐,涉及的方法都是 sendEvent: 祭埂,而該方法的參數(shù)(UIEvent對象)是唯一貫穿整個經(jīng)過的線索,那么就可以大膽猜測必然是該觸摸事件對象上綁定了這些信息兵钮。事實上之前在介紹UITouch的時候就說過touch對象保存了觸摸所屬的window及view蛆橡,而event對象又綁定了touch對象,如此一來掘譬,就說得通了泰演。怎么驗證猜測是否為真呢?
自定義一個Window類葱轩,重寫 sendEvent: 方法睦焕,捕捉該方法調(diào)用時參數(shù)event的狀態(tài),如下圖所示:
這兩個屬性是什么時候綁定到touch對象上的呢靴拱?應(yīng)該是在hit-testing過程中垃喊。其實hit-testing本質(zhì)上做的事情,也就是將這些信息綁定到touch對象上袜炕。
事件的響應(yīng)
前面介紹UIResponder的時候說過本谜,每個響應(yīng)者必定都是UIResponder對象,通過4個響應(yīng)觸摸事件的方法來響應(yīng)事件偎窘。每個UIResponder對象默認(rèn)都已經(jīng)實現(xiàn)了這4個方法乌助,但是默認(rèn)不對事件做任何處理,單純只是將事件沿著響應(yīng)鏈傳遞陌知。若要截獲事件進(jìn)行自定義的響應(yīng)操作眷茁,就要重寫相關(guān)的方法。例如纵诞,通過重寫 touchesMoved: withEvent: 方法實現(xiàn)簡單的視圖拖動。
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
每個響應(yīng)觸摸事件的方法都會接收兩個參數(shù)培遵,分別對應(yīng)觸摸對象集合和事件對象浙芙。視圖(UIView)作為響應(yīng)者對象登刺,本身已經(jīng)實現(xiàn)了 touchesMoved: withEvent: 方法,具體怎么實現(xiàn)的蘋果沒有開源給我們嗡呼。因此如果想定義自己的響應(yīng)行為纸俭,必須創(chuàng)建一個自定義視圖(繼承自UIView),然后重寫那幾個方法南窗。
這里以touchMoved方法為例揍很,實現(xiàn)視圖隨著手指移動而移動的響應(yīng)事件:
//MovedView
//重寫touchesMoved方法(觸摸滑動過程中持續(xù)調(diào)用)
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
//獲取觸摸對象
UITouch *touch = [touches anyObject];
//獲取前一個觸摸點位置
CGPoint prePoint = [touch previousLocationInView:self];
//獲取當(dāng)前觸摸點位置
CGPoint curPoint = [touch locationInView:self];
//計算偏移量
CGFloat offsetX = curPoint.x - prePoint.x;
CGFloat offsetY = curPoint.y - prePoint.y;
//相對之前的位置偏移視圖
self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}
每個響應(yīng)者都有權(quán)決定是否執(zhí)行對事件的響應(yīng),只要重寫相關(guān)的觸摸事件方法即可万伤。
事件沿著響應(yīng)鏈傳遞
前面一直在提最佳響應(yīng)者窒悔,之所以稱之為“最佳”,是因為其具備響應(yīng)事件的最高優(yōu)先權(quán)敌买。最佳響應(yīng)者首先接收到事件简珠,然后便擁有了對事件的絕對控制權(quán):即它可以選擇獨吞這個事件,也可以將這個事件往下傳遞給其他響應(yīng)者虹钮,這個由響應(yīng)者構(gòu)成的視圖鏈就稱之為響應(yīng)鏈聋庵。
響應(yīng)者對于事件的操作方式:
響應(yīng)者對于接收到的事件有3種操作:
- 不攔截,默認(rèn)操作:事件會自動沿著默認(rèn)的響應(yīng)鏈往下傳遞
- 攔截芙粱,不再往下分發(fā)事件:重寫 touchesBegan:withEvent: 進(jìn)行事件處理祭玉,不調(diào)用父類的 touchesBegan:withEvent:
- 攔截,繼續(xù)往下分發(fā)事件:重寫 touchesBegan:withEvent: 進(jìn)行事件處理春畔,同時調(diào)用父類的 touchesBegan:withEvent: 將事件往下傳遞
響應(yīng)鏈中的事件傳遞規(guī)則
每一個響應(yīng)者對象(UIResponder對象)都有一個 nextResponder 方法脱货,用于獲取響應(yīng)鏈中當(dāng)前對象的下一個響應(yīng)者。因此拐迁,一旦事件的最佳響應(yīng)者確定了蹭劈,這個事件所處的響應(yīng)鏈就確定了。
對于響應(yīng)者對象线召,默認(rèn)的 nextResponder 實現(xiàn)如下:
- UIView
若視圖是控制器的根視圖铺韧,則其nextResponder為控制器對象;否則缓淹,其nextResponder為父視圖哈打。 - UIViewController
若控制器的視圖是window的根視圖,則其nextResponder為窗口對象讯壶;若控制器是從別的控制器present出來的料仗,則其nextResponder為presenting view controller。 - UIWindow
nextResponder為UIApplication對象伏蚊。 - UIApplication
若當(dāng)前應(yīng)用的app delegate是一個UIResponder對象立轧,且不是UIView斑粱、UIViewController或app本身煤傍,則UIApplication的nextResponder為app delegate。
事件的三徒弟UIResponder、UIGestureRecognizer娜庇、UIControl
iOS中俭厚,除了UIResponder能夠響應(yīng)事件丧靡,手勢識別器综膀、UIControl同樣具備對事件的處理能力。當(dāng)這幾者同時存在于某一場景下的時候葛躏,事件又是怎么處理的呢澈段?
測試場景
app界面如下
界面代碼如下
- (void)viewDidLoad {
[super viewDidLoad];
//底部是一個綁定了單擊手勢的backView
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTapView)];
[_backView addGestureRecognizer:tap];
//上面是一個常規(guī)的tableView
_tableMain.tableFooterView = [UIView new];
//還有一個和tableView同級的button
[_button addTarget:self action:@selector(buttonTap) forControlEvents:UIControlEventTouchUpInside];
}
- (void)actionTapView{
NSLog(@"backview taped");
}
- (void)buttonTap {
NSLog(@"button clicked!");
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
NSLog(@"cell selected!");
}
這個時候我們點擊cell,會發(fā)現(xiàn)點不動舰攒,或者說點了沒有任何反應(yīng)败富。但是如果我們長按cell一段時間,發(fā)現(xiàn)cell又可以被點擊芒率。點擊下面的button囤耳,一切如常。為什么會發(fā)生這種情況呢偶芍?
為了測試結(jié)果充择,我們自定義相關(guān)的控件類,均重寫4個響應(yīng)觸摸事件的方法用來打印日志(每個重寫的觸摸事件方法都調(diào)用了父類的方法以保證事件默認(rèn)傳遞邏輯)匪蟀。
觀察各種情況下的日志現(xiàn)象:
- 現(xiàn)象一 快速點擊cell
backview taped
- 現(xiàn)象二 短按cell
-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]
- 現(xiàn)象三 長按cell
-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!
- 現(xiàn)象四 點擊button
-[GLButton touchesBegan:withEvent:]
-[GLButton touchesEnded:withEvent:]
button clicked!
想要了解到底發(fā)生了什么椎麦,就不得不提到觸摸事件響應(yīng)的二徒弟,手勢識別器UIGestureRecognizer
手勢識別器UIGestureRecognizer
事實上材彪,手勢分為離散型手勢(discrete gestures)和持續(xù)型手勢(continuous gesture)观挎。系統(tǒng)提供的離散型手勢包括點按手勢([UITapGestureRecognizer]和輕掃手勢([UISwipeGestureRecognizer],其余均為持續(xù)型手勢段化。
兩者主要區(qū)別在于狀態(tài)變化過程:
- 離散型:
識別成功:Possible —> Recognized
識別失斷医荨:Possible —> Failed - 持續(xù)型:
完整識別:Possible —> Began —> [Changed] —> Ended
不完整識別:Possible —> Began —> [Changed] —> Cancel
離散型手勢
先拋開上面的場景,看一個簡單的demo显熏。
控制器的根視圖上添加了一個YellowView雄嚣,并給根視圖綁定了一個單擊手勢識別器。
// HuViewController
- (void)viewDidLoad {
[super viewDidLoad];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTap)];
[self.view addGestureRecognizer:tap];
}
- (void)actionTap{
NSLog(@"View Taped");
}
單擊YellowView喘蟆,日志打印如下:
-[YellowView touchesBegan:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]
從日志上看出YellowView最后Cancel了對觸摸事件的響應(yīng)缓升,而正常應(yīng)當(dāng)是觸摸結(jié)束后,YellowView的 touchesEnded:withEvent:
的方法被調(diào)用才對蕴轨。另外港谊,期間還執(zhí)行了手勢識別器綁定的action 。
官方文檔給出了這樣的解釋:
A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer. Generally, if a gesture recognizer analyzes the stream of touches in a multi-touch sequence and doesn’t recognize its gesture, the view receives the full complement of touches. If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled.The usual sequence of actions in gesture recognition follows a path determined by default values of the cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEndedproperties.
大致含義是橙弱,Window在將事件傳遞給hit-tested view(最佳響應(yīng)者)之前歧寺,會先將事件傳遞給相關(guān)的手勢識別器并由手勢識別器優(yōu)先識別燥狰。若手勢識別器成功識別了事件,就會取消hit-tested view對事件的響應(yīng)成福;若手勢識別器沒能識別事件碾局,hit-tested view才完全接手事件的響應(yīng)權(quán)。即手勢識別器比UIResponder具有更高的事件響應(yīng)優(yōu)先級奴艾!
按照這個解釋,Window在將事件傳遞給hit-tested view即YellowView之前内斯,先傳遞給了控制器根視圖上的手勢識別器蕴潦。手勢識別器成功識別了該事件,通知Application取消YellowView對事件的響應(yīng)俘闯。
然而看日志潭苞,卻是YellowView的 touchesBegan:withEvent:
先調(diào)用了,既然手勢識別器先響應(yīng)真朗,不應(yīng)該上面的action先執(zhí)行嗎此疹?其實這里存在一個認(rèn)知錯誤,手勢識別器的action的調(diào)用時機(jī)并不是手勢識別器接收到事件的時機(jī)遮婶,而是手勢識別器成功識別事件后的時機(jī)蝗碎,即手勢識別器的狀態(tài)變?yōu)閁IGestureRecognizerStateRecognized。因此從該日志中并不能看出事件是優(yōu)先傳遞給手勢識別器的旗扑,那該怎么證明Window先將事件傳遞給了手勢識別器蹦骑?
要解決這個問題,只要知道手勢識別器是如何接收事件的臀防,然后在接收事件的方法中打印日志對比調(diào)用時間先后即可眠菇。說出來你可能不信,手勢識別器對于事件的響應(yīng)也是通過這4個熟悉的方法來實現(xiàn)的袱衷。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
注意捎废,雖然手勢識別器通過這幾個方法來響應(yīng)事件,但它并不是UIResponder的子類致燥,這幾個方法的聲明在 UIGestureRecognizerSubclass.h
中登疗,跟UIResponder中的方法實現(xiàn)并不是一回事。
這樣一來篡悟,我們便可以自定義一個單擊手勢識別器的類谜叹,重寫這幾個方法來監(jiān)聽手勢識別器接收事件的時機(jī)。創(chuàng)建一個UITapGestureRecognizer的子類搬葬,重寫響應(yīng)事件的方法荷腊,每個方法中調(diào)用父類的實現(xiàn),并替換demo中的手勢識別器急凰。另外需要在.m文件中引入 import <UIKit/UIGestureRecognizerSubclass.h>
女仰,因為相關(guān)方法聲明在該頭文件中猜年。
// HuTapGestureRecognizer (繼承自UITapGestureRecognizer)
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
[super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
[super touchesCancelled:touches withEvent:event];
}
現(xiàn)在,再次點擊YellowView疾忍,日志如下:
-[HuTapGestureRecognizer touchesBegan:withEvent:]
-[YellowView touchesBegan:withEvent:]
-[HuTapGestureRecognizer touchesEnded:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]
很明顯乔外,確實是手勢識別器先接收到了事件。之后手勢識別器成功識別了手勢一罩,執(zhí)行了action杨幼,再由Application取消了YellowView對事件的響應(yīng)。
Window怎么知道要把事件傳遞給哪些手勢識別器聂渊?
之前探討過Application怎么知道要把event傳遞給哪個Window差购,以及Window怎么知道要把event傳遞給哪個hit-tested view的問題,答案是這些信息都保存在event所綁定的touch對象上汉嗽。手勢識別器也是一樣的欲逃,event綁定的touch對象上維護(hù)了一個手勢識別器數(shù)組,里面的手勢識別器是在hit-testing的過程中收集的饼暑。打個斷點看一下touch上綁定的手勢識別器數(shù)組:
Window先將事件傳遞給這些手勢識別器稳析,再傳給hit-tested view。一旦有手勢識別器成功識別了手勢弓叛,Application就會取消hit-tested view對事件的響應(yīng)彰居。
持續(xù)型手勢
將上面Demo中視圖綁定的單擊手勢識別器用滑動手勢識別器(UIPanGestureRecognizer)替換。
- (void)viewDidLoad {
[super viewDidLoad];
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(actionPan)];
[self.view addGestureRecognizer:pan];
}
- (void)actionPan{
NSLog(@"View panned");
}
在YellowView上執(zhí)行一次滑動邪码,日志打印如下:
-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesCancelled:withEvent:]
View panned
View panned
View panned
...
在一開始滑動的過程中裕菠,手勢識別器處在識別手勢階段,滑動產(chǎn)生的連續(xù)事件既會傳遞給手勢識別器又會傳遞給YellowView闭专,因此YellowView的 touchesMoved:withEvent:
在開始一段時間內(nèi)會持續(xù)調(diào)用奴潘;當(dāng)手勢識別器成功識別了該滑動手勢時,手勢識別器的action開始調(diào)用影钉,同時通知Application取消YellowView對事件的響應(yīng)画髓。之后僅由滑動手勢識別器接收事件并響應(yīng),YellowView不再接收事件平委。
另外奈虾,在滑動的過程中,若手勢識別器未能識別手勢廉赔,則事件在觸摸滑動過程中會一直傳遞給hit-tested view肉微,直到觸摸結(jié)束。
總結(jié)一下手勢識別器與UIResponder對于事件響應(yīng)的聯(lián)系:
當(dāng)觸摸發(fā)生或者觸摸的狀態(tài)發(fā)生變化時蜡塌,Window都會傳遞事件尋求響應(yīng)碉纳。
- Window先將綁定了觸摸對象的事件傳遞給觸摸對象上綁定的手勢識別器,再發(fā)送給觸摸對象對應(yīng)的hit-tested view馏艾。
- 手勢識別器識別手勢期間劳曹,若觸摸對象的觸摸狀態(tài)發(fā)生變化奴愉,事件都是先發(fā)送給手勢識別器再發(fā)送給hit-test view。
- 手勢識別器若成功識別了手勢铁孵,則通知Application取消hit-tested view對于事件的響應(yīng)锭硼,并停止向hit-tested view發(fā)送事件;
- 若手勢識別器未能識別手勢蜕劝,而此時觸摸并未結(jié)束檀头,則停止向手勢識別器發(fā)送事件,僅向hit-test view發(fā)送事件熙宇。
- 若手勢識別器未能識別手勢鳖擒,且此時觸摸已經(jīng)結(jié)束,則向hit-tested view發(fā)送end狀態(tài)的touch事件以停止對事件的響應(yīng)烫止。
手勢識別器的3個屬性
@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;
cancelsTouchesInView
默認(rèn)為YES。表示當(dāng)手勢識別器成功識別了手勢之后戳稽,會通知Application取消響應(yīng)鏈對事件的響應(yīng)馆蠕,并不再傳遞事件給hit-test view。若設(shè)置成NO惊奇,表示手勢識別成功后不取消響應(yīng)鏈對事件的響應(yīng)互躬,事件依舊會傳遞給hit-test view。
demo中設(shè)置: pan.cancelsTouchesInView = NO
滑動時日志如下:
-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]
View panned
View panned
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]
...
即便滑動手勢識別器識別了手勢颂郎,Application也會依舊發(fā)送事件給YellowView吼渡。
delaysTouchesBegan
默認(rèn)為NO。默認(rèn)情況下手勢識別器在識別手勢期間乓序,當(dāng)觸摸狀態(tài)發(fā)生改變時寺酪,Application都會將事件傳遞給手勢識別器和hit-tested view;若設(shè)置成YES替劈,則表示手勢識別器在識別手勢期間寄雀,截斷事件,即不會將事件發(fā)送給hit-tested view陨献。
設(shè)置 pan.delaysTouchesBegan = YES
日志如下:
View panned
View panned
View panned
View panned
...
因為滑動手勢識別器在識別期間盒犹,事件不會傳遞給YellowView,因此期間YellowView的 touchesBegan:withEvent:
和 touchesMoved:withEvent:
都不會被調(diào)用眨业;而后滑動手勢識別器成功識別了手勢急膀,也就獨吞了事件,不會再傳遞給YellowView龄捡。因此只打印了手勢識別器成功識別手勢后的action調(diào)用卓嫂。
delaysTouchesEnded
默認(rèn)為NO。默認(rèn)情況下當(dāng)手勢識別器未能識別手勢時墅茉,若此時觸摸已經(jīng)結(jié)束命黔,則會立即通知Application發(fā)送狀態(tài)為end的touch事件給hit-tested view以調(diào)用 touchesEnded:withEvent:
結(jié)束事件響應(yīng)呜呐;若設(shè)置為YES,則會在手勢識別失敗時悍募,延遲一小段時間(0.15s)再調(diào)用響應(yīng)者的 touchesEnded:withEvent:
蘑辑。
總結(jié):手勢識別器比響應(yīng)鏈具有更高的事件響應(yīng)優(yōu)先級。
大徒弟—UIControl
UIControl是系統(tǒng)提供的能夠以target-action模式處理觸摸事件的控件坠宴,iOS中UIButton洋魂、UISegmentedControl、UISwitch等控件都是UIControl的子類喜鼓。當(dāng)UIControl跟蹤到觸摸事件時副砍,會向其上添加的target發(fā)送事件以執(zhí)行action。值得注意的是庄岖,UIConotrol是UIView的子類豁翎,因此本身也具備UIResponder應(yīng)有的身份。
關(guān)于UIControl隅忿,此處介紹兩點:
- target-action執(zhí)行時機(jī)及過程
- 觸摸事件優(yōu)先級
UIControl作為能夠響應(yīng)事件的控件心剥,必然也需要待事件交互符合條件時才去響應(yīng),因此也會跟蹤事件發(fā)生的過程背桐。不同于UIControl以及UIGestureRecognizer通過 touches 系列方法跟蹤优烧,UIControl有其獨特的跟蹤方式:
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)cancelTrackingWithEvent:(nullable UIEvent *)event;
乍一看,這4個方法和UIResponder的那4個方法幾乎吻合链峭,只不過UIControl只能接收單點觸控畦娄,因此接收的參數(shù)是單個UITouch對象。這幾個方法的職能也和UIResponder一致弊仪,用來跟蹤觸摸的開始熙卡、滑動、結(jié)束撼短、取消再膳。不過,UIControl本身也是UIResponder曲横,因此同樣有 touches 系列的4個方法喂柒。
事實上,UIControl的 Tracking 系列方法是在 touch 系列方法內(nèi)部調(diào)用的禾嫉。比如 beginTrackingWithTouch 是在 touchesBegan 方法內(nèi)部調(diào)用的灾杰, 因此它雖然也是UIResponder,但 touches 系列方法的默認(rèn)實現(xiàn)和UIResponder本類還是有區(qū)別的熙参。
當(dāng)UIControl跟蹤事件的過程中艳吠,識別出事件交互符合響應(yīng)條件,就會觸發(fā)target-action進(jìn)行響應(yīng)孽椰。UIControl控件通過 addTarget:action:forControlEvents: 添加事件處理的target和action昭娩,當(dāng)事件發(fā)生時凛篙,UIControl通知target執(zhí)行對應(yīng)的action。說是“通知”其實很籠統(tǒng)栏渺,事實上這里有個action傳遞的過程呛梆。當(dāng)UIControl監(jiān)聽到需要處理的交互事件時,會調(diào)用 sendAction:to:forEvent: 將target磕诊、action以及event對象發(fā)送給全局應(yīng)用填物,Application對象再通過 sendAction:to:from:forEvent: 向target發(fā)送action。
因此霎终,可以通過重寫UIControl的 sendAction:to:forEvent: 或 sendAction:to:from:forEvent: 自定義事件執(zhí)行的target及action滞磺。
另外,若不指定target莱褒,即 addTarget:action:forControlEvents: 時target傳空击困,那么當(dāng)事件發(fā)生時,Application會在響應(yīng)鏈上從上往下尋找能響應(yīng)action的對象广凸。
觸摸事件優(yōu)先級
當(dāng)原本關(guān)系已經(jīng)錯綜復(fù)雜的UIGestureRecognizer和UIResponder之間又冒出一個UIControl沛励,觸摸事件的優(yōu)先級又成了什么樣呢?
UIControl會阻止父視圖上的手勢識別器行為炮障,也就是UIControl處理事件的優(yōu)先級比UIGestureRecognizer高,但前提是相比于父視圖上的手勢識別器坤候。
測試場景:在BlueView上添加一個button胁赢,同時給button添加一個target-action事件。
- 示例一:在BlueView上添加點擊手勢識別器
- 示例二:在button上添加手勢識別器
測試結(jié)果:示例一中白筹,button的target-action響應(yīng)了單擊事件智末;示例二中,BlueView上的手勢識別器響應(yīng)了事件徒河。過程日志打印如下:
//示例一
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:] after called state = 5
-[CLButton touchesEnded:withEvent:]
-[CLButton endTrackingWithTouch:withEvent:]
按鈕點擊行為
//示例二
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:] after called state = 3
手勢觸發(fā)行為
-[CLButton touchesCancelled:withEvent:]
-[CLButton cancelTrackingWithEvent:]
原因分析:點擊button后系馆,事件先傳遞給手勢識別器,再傳遞給作為hit-tested view存在的button(UIControl本身也是UIResponder顽照,這一過程和普通事件響應(yīng)者無異)由蘑。示例一中,由于button阻止了父視圖BlueView中的手勢識別器的識別代兵,導(dǎo)致手勢識別器識別失斈崮稹(狀態(tài)為failed 枚舉值為5),button完全接手了事件的響應(yīng)權(quán)植影,事件最終由button響應(yīng)裳擎;示例二中,button未阻止其本身綁定的手勢識別器的識別思币,因此手勢識別器先識別手勢并識別成功(狀態(tài)為ended 枚舉值為3)鹿响,而后通知Application取消響應(yīng)鏈對事件的響應(yīng)羡微,因為 touchesCancelled 被調(diào)用,同時 cancelTrackingWithEvent 跟著調(diào)用惶我,因此button的target-action得不到執(zhí)行妈倔。
結(jié)論:UIControl比其父視圖上的手勢識別器具有更高的事件響應(yīng)優(yōu)先級。
解惑
回到本節(jié)開始時的測試?yán)又衼碇腹拢姆N現(xiàn)象得到了解釋启涯。
先看現(xiàn)象二,短按 cell無法響應(yīng)恃轩,日志如下:
-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]
這個日志和上面離散型手勢Demo中打印的日志完全一致结洼。短按后,BackView上的手勢識別器先接收到事件叉跛,之后事件傳遞給hit-tested view松忍,作為響應(yīng)者鏈中一員的GLTableView的 touchesBegan:withEvent:
被調(diào)用;而后手勢識別器成功識別了點擊事件筷厘,action執(zhí)行鸣峭,同時通知Application取消響應(yīng)鏈中的事件響應(yīng),GLTableView的 touchesCancelled:withEvent:
被調(diào)用酥艳。
因為事件被取消了摊溶,因此Cell無法響應(yīng)點擊。
再看現(xiàn)象三充石,長按cell能夠響應(yīng)莫换,日志如下:
-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!
長按的過程中,一開始事件同樣被傳遞給手勢識別器和hit-tested view骤铃,作為響應(yīng)鏈中一員的GLTableView的 touchesBegan:withEvent:
被調(diào)用拉岁;此后在長按的過程中,手勢識別器一直在識別手勢惰爬,直到一定時間后手勢識別失敗喊暖,才將事件的響應(yīng)權(quán)完全交給響應(yīng)鏈。當(dāng)觸摸結(jié)束的時候撕瞧,GLTableView的 touchesEnded:withEvent:
被調(diào)用陵叽,同時Cell響應(yīng)了點擊。
現(xiàn)在回到現(xiàn)象一风范。按照之前的分析咨跌,快速點擊cell,講道理不管是表現(xiàn)還是日志都應(yīng)該和現(xiàn)象二一致才對硼婿。然而日志僅僅打印了手勢識別器的action執(zhí)行結(jié)果锌半。分析一下原因:GLTableView的 touchesBegan
沒有調(diào)用,說明事件沒有傳遞給hit-tested view。那只有一種可能刊殉,就是事件被某個手勢識別器攔截了殉摔。目前已知的手勢識別器攔截事件的方法,就是設(shè)置 delaysTouchesBegan
為YES记焊,在手勢識別器未識別完成的情況下不會將事件傳遞給hit-tested view逸月。然后事實上并沒有進(jìn)行這樣的設(shè)置,那么問題可能出在別的手勢識別器上遍膜。
其實是蘋果內(nèi)部的機(jī)制造成了這種現(xiàn)象碗硬,細(xì)節(jié)請查看此處。大概理解為:某一個類攔截了事件并延遲了0.15s發(fā)送瓢颅。又因為點擊時間比0.15s短恩尾,在發(fā)送事件前觸摸就結(jié)束了,因此事件沒有傳遞到hit-tested view挽懦,導(dǎo)致TableView的 touchBegin
沒有調(diào)用翰意。而現(xiàn)象二,由于短按的時間超過了0.15s信柿,手勢識別器攔截了事件并經(jīng)過0.15s后冀偶,觸摸還未結(jié)束,于是將事件傳遞給了hit-tested view渔嚷,使得TableView接收到了事件进鸠。因此現(xiàn)象二的日志雖然和離散型手勢Demo中的日志一致,但實際上前者的hit-tested view是在觸摸后延遲了約0.15s左右才接收到觸摸事件的形病。
總結(jié)
- 觸摸發(fā)生時堤如,系統(tǒng)內(nèi)核生成觸摸事件,先由IOKit處理封裝成IOHIDEvent對象窒朋,通過IPC傳遞給系統(tǒng)進(jìn)程SpringBoard,而后再傳遞給前臺APP處理蝗岖。
- 事件傳遞到APP內(nèi)部時被封裝成開發(fā)者可見的UIEvent對象侥猩,先經(jīng)過hit-testing尋找第一響應(yīng)者,而后由Window對象將事件傳遞給hit-tested view抵赢,并開始在響應(yīng)鏈上的傳遞欺劳。
- UIRespnder、UIGestureRecognizer铅鲤、UIControl都可以對觸摸事件作出響應(yīng)划提,UIGestureRecognizer優(yōu)先級高于UIRespnder,而UIControl又高于父控件的UIGestureRecognizer邢享。如果是同級控件鹏往,那么UIControl可以看做是一個普通的UIRespnder來對待,即優(yōu)先級低于UIRespnder骇塘。