ios 触摸事件 学习
好奇觸摸事件是如何從屏幕轉移到APP內的?
困惑于Cell怎么突然不能點擊了?
糾結于如何實現這個奇葩響應需求?
亦或是已經被響應鏈、手勢、target-action這一系列響應觸摸事件的方式折騰到不會打Hello World?
現在 是時候帶你上分了~ (強行YY完畢)
本文主要講解iOS觸摸事件的一系列機制,涉及的問題大致包括:
- 觸摸事件由觸屏生成后如何傳遞到當前應用?
- 應用接收觸摸事件后如何尋找最佳響應者?實現原理?
- 觸摸事件如何沿著響應鏈流動?
- 響應鏈、手勢識別器、UIControl之間對于觸摸事件的響應有著什么樣的瓜葛?
tips: iOS中的事件除了觸摸事件,還包括加速計事件、遠程控制事件。由于兩者不在本文討論范疇,因此文中所說事件均特指觸摸事件。
事件的生命周期
當指尖觸碰屏幕的那一刻,一個觸摸事件就在系統中生成了。經過IPC進程間通信,事件最終被傳遞到了合適的應用。在應用內歷經峰回路轉的奇幻之旅后,最終被釋放。大致經過如下圖:
觸摸事件流動過程 圖片來源(http://qingmo.me/2017/03/04/FlowOfUITouch/)
系統響應階段
手指觸碰屏幕,屏幕感應到觸碰后,將事件交由IOKit處理。
IOKit將觸摸事件封裝成一個IOHIDEvent對象,并通過mach port傳遞給SpringBoad進程。
mach port 進程端口,各進程之間通過它進行通信。
SpringBoad.app 是一個系統進程,可以理解為桌面系統,可以統一管理和分發系統接收到的觸摸事件。
SpringBoard進程因接收到觸摸事件,觸發了主線程runloop的source1事件源的回調。
此時SpringBoard會根據當前桌面的狀態,判斷應該由誰處理此次觸摸事件。因為事件發生時,你可能正在桌面上翻頁,也可能正在刷微博。若是前者(即前臺無APP運行),則觸發SpringBoard本身主線程runloop的source0事件源的回調,將事件交由桌面系統去消耗;若是后者(即有app正在前臺運行),則將觸摸事件通過IPC傳遞給前臺APP進程,接下來的事情便是APP內部對于觸摸事件的響應了。
APP響應階段
現在,你可以回答第一個問題了。觸摸事件從觸屏產生后,由IOKit將觸摸事件傳遞給SpringBoard進程,再由SpringBoard分發給當前前臺APP處理。
觸摸、事件、響應者
說了那么多,到底什么是觸摸、什么是事件、什么是響應者?先簡單科普一下。
UITouch
源起觸摸
- 一個手指一次觸摸屏幕,就對應生成一個UITouch對象。多個手指同時觸摸,生成多個UITouch對象。
- 多個手指先后觸摸,系統會根據觸摸的位置判斷是否更新同一個UITouch對象。若兩個手指一前一后觸摸同一個位置(即雙擊),那么第一次觸摸時生成一個UITouch對象,第二次觸摸更新這個UITouch對象(UITouch對象的 tap count 屬性值從1變成2);若兩個手指一前一后觸摸的位置不同,將會生成兩個UITouch對象,兩者之間沒有聯系。
- 每個UITouch對象記錄了觸摸的一些信息,包括觸摸時間、位置、階段、所處的視圖、窗口等信息。
- 手指離開屏幕一段時間后,確定該UITouch對象不會再被更新將被釋放。
UIEvent
事件的真身
- 觸摸的目的是生成觸摸事件供響應者響應,一個觸摸事件對應一個UIEvent對象,其中的 type 屬性標識了事件的類型(之前說過事件不只是觸摸事件)。
- UIEvent對象中包含了觸發該事件的觸摸對象的集合,因為一個觸摸事件可能是由多個手指同時觸摸產生的。觸摸對象集合通過 allTouches 屬性獲取。
UIResponder
一切為了滿足它的野心
每個響應者都是一個UIResponder對象,即所有派生自UIResponder的對象,本身都具備響應事件的能力。因此以下類的實例都是響應者:
- UIView
- UIViewController
- UIApplication
- AppDelegate
響應者之所以能響應事件,因為其提供了4個處理觸摸事件的方法:
//手指觸碰屏幕,觸摸開始 - (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;這幾個方法在響應者對象接收到事件的時候調用,用于做出對事件的響應。關于響應者何時接收到事件以及事件如何沿著響應鏈傳遞將在下面章節說明。
尋找事件的最佳響應者(Hit-Testing)
第一節講過APP接收到觸摸事件后,會被放入當前應用的一個事件隊列中(PS為什么是隊列而不是棧?很好理解因為觸摸事件必然是先發生先執行,切合隊列FIFO的原則)。
每個事件的理想宿命是被能夠響應它的對象響應后釋放,然而響應者諸多,事件一次只有一個,誰都想把事件搶到自己碗里來,為避免紛爭,就得有一個先后順序,也就是得有一個響應者的優先級。因此這就存在一個尋找事件最佳響應者(又稱第一響應者 first responder)的過程,目的是找到一個具備最高優先級響應權的響應對象(the most appropriate responder object),這個過程叫做Hit-Testing,那個命中的最佳響應者稱為hit-tested view。
本節要探討的問題是:
事件自下而上的傳遞
應用接收到事件后先將其置入事件隊列中以等待處理。出隊后,application首先將事件傳遞給當前應用最后顯示的窗口(UIWindow)詢問其能否響應事件。若窗口能響應事件,則傳遞給子視圖詢問是否能響應,子視圖若能響應則繼續詢問子視圖。子視圖詢問的順序是優先詢問后添加的子視圖,即子視圖數組中靠后的視圖。事件傳遞順序如下:
UIApplication ——> UIWindow ——> 子視圖 ——> ... ——> 子視圖事實上把UIWindow也看成是視圖即可,這樣整個傳遞過程就是一個遞歸詢問子視圖能否響應事件過程,且后添加的子視圖優先級高(對于window而言就是后顯示的window優先級高)。
具體流程如下:
示例:
hit-testing 場景
視圖層級如下(同一層級的視圖越在下面,表示越后添加):
A ├── B │ └── D └── C├── E└── F現在假設在E視圖所處的屏幕位置觸發一個觸摸,應用接收到這個觸摸事件事件后,先將事件傳遞給UIWindow,然后自下而上開始在子視圖中尋找最佳響應者。事件傳遞的順序如下所示:
hit-testing 過程
Hit-Testing的本質
上面講了事件在響應者之間傳遞的規則,視圖通過判斷自身能否響應事件來決定是否繼續向子視圖傳遞。那么問題來了:視圖如何判斷能否響應事件?以及視圖如何將事件傳遞給子視圖?
首先要知道的是,以下幾種狀態的視圖無法響應事件:
- 不允許交互:userInteractionEnabled = NO
- 隱藏:hidden = YES 如果父視圖隱藏,那么子視圖也會隱藏,隱藏的視圖無法接收事件
- 透明度:alpha < 0.01 如果設置一個視圖的透明度<0.01,會直接影響子視圖的透明度。alpha:0.0~0.01為透明。
hitTest:withEvent:
每個UIView對象都有一個 hitTest:withEvent: 方法,這個方法是Hit-Testing過程中最核心的存在,其作用是詢問事件在當前視圖中的響應者,同時又是作為事件傳遞的橋梁。
hitTest:withEvent: 方法返回一個UIView對象,作為當前視圖層次中的響應者。默認實現是:
- 若當前視圖無法響應事件,則返回nil
- 若當前視圖可以響應事件,但無子視圖可以響應事件,則返回自身作為當前視圖層次中的事件響應者
- 若當前視圖可以響應事件,同時有子視圖可以響應,則返回子視圖層次中的事件響應者
一開始UIApplication將事件通過調用UIWindow對象的 hitTest:withEvent: 傳遞給UIWindow對象,UIWindow的 hitTest:withEvent: 在執行時若判斷本身能響應事件,則調用子視圖的 hitTest:withEvent: 將事件傳遞給子視圖并詢問子視圖上的最佳響應者。最終UIWindow返回一個視圖層次中的響應者視圖給UIApplication,這個視圖就是hit-testing的最佳響應者。
系統對于視圖能否響應事件的判斷邏輯除了之前提到的3種限制狀態,默認能響應的條件就是觸摸點在當前視圖的坐標系范圍內。因此,hitTest:withEvent: 的默認實現就可以推測了,大致如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{//3種狀態無法響應事件if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil; //觸摸點若不在當前視圖上則無法響應事件if ([self pointInside:point withEvent:event] == NO) return nil; //從后往前遍歷子視圖數組 int count = (int)self.subviews.count; for (int i = count - 1; i >= 0; i--) { // 獲取子視圖UIView *childView = self.subviews[i]; // 坐標系的轉換,把觸摸點在當前視圖上坐標轉換為在子視圖上的坐標CGPoint childP = [self convertPoint:point toView:childView]; //詢問子視圖層級中的最佳響應視圖UIView *fitView = [childView hitTest:childP withEvent:event]; if (fitView) {//如果子視圖中有更合適的就返回return fitView; }} //沒有在子視圖中找到更合適的響應視圖,那么自身就是最合適的return self; }值得注意的是 pointInside:withEvent: 這個方法,用于判斷觸摸點是否在自身坐標范圍內。默認實現是若在坐標范圍內則返回YES,否則返回NO。
現在我們在上述示例的視圖層次中的每個視圖類中添加下面3個方法來驗證一下之前的分析(注意 hitTest:withEvent: 和 pointInside:withEvent: 方法都要調用父類的實現,否則不會按照默認的邏輯來執行Hit-Testing):
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{NSLog(@"%s",__func__);return [super hitTest:point withEvent:event]; } - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{NSLog(@"%s",__func__);return [super pointInside:point withEvent:event]; } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{NSLog(@"%s",__func__); }單點觸摸視圖E,相關日志打印如下:
-[AView hitTest:withEvent:] -[AView pointInside:withEvent:] -[CView hitTest:withEvent:] -[CView pointInside:withEvent:] -[FView hitTest:withEvent:] -[FView pointInside:withEvent:] -[EView hitTest:withEvent:] -[EView pointInside:withEvent:] -[EView touchesBegan:withEvent:]可以看到最終是視圖E先對事件進行了響應,同時事件傳遞過程也和之前的分析一致。事實上單擊后從 [AView hitTest:withEvent:] 到 [EView pointInside:withEvent:] 的過程會執行兩遍,兩次傳的是同一個touch,區別在于touch的狀態不同,第一次是begin階段,第二次是end階段。也就是說,應用對于事件的傳遞起源于觸摸狀態的變化。
Hit-Testing過程中的事件攔截(自定義事件流向)
實際開發中可能會遇到一些特殊的交互需求,需要定制視圖對于事件的響應。例如下面Tabbar的這種情況,中間的原型按鈕是底部Tabbar上的控件,而Tabbar是添加在控制器根視圖中的。默認情況下我們點擊圖中紅色方框中按鈕的區域,會發現按鈕并不會得到響應。
hit-testing過程中事件攔截場景
分析一下原因其實很容易就能明白問題所在。忽略不相關的控件,視圖層次如下:
RootView └── TableView └── TabBar└── CircleButton點擊紅色方框區域后,生成的觸摸事件首先傳到UIWindow,然后傳到控制器的根視圖即RootView。RootView經判斷可以響應觸摸事件,而后將事件傳給了子控件TabBar。問題就出在這里,因為觸摸點不在TabBar的坐標范圍內,因此TabBar無法響應該觸摸事件,hitTest:withEvent: 直接返回了nil。而后RootView就會詢問TableView是否能夠響應,事實上是可以的,因此事件最終被TableView消耗。整個過程,事件根本沒有傳遞到圓形按鈕。
有問題就會有解決策略。經過分析,發現原因是hit-Testing的過程中,事件在傳遞到TabBar的時候沒能繼續往CircleButton傳,因為點擊區域坐標不在Tabbar的坐標范圍內,因此Tabbar被識別成了無法響應事件。既然如此,我們可以修改事件hit-Testing的過程,當點擊紅色方框區域時讓事件流向原型按鈕。
事件傳遞到TabBar時,TabBar的 hitTest:withEvent: 被調用,但是 pointInside:withEvent: 會返回NO,如此一來 hitTest:withEvent: 返回了nil。既然如此,可以重寫TabBard的 pointInside:withEvent: ,判斷當前觸摸坐標是否在子視圖CircleButton的坐標范圍內,若在,則返回YES,反之返回NO。這樣一來點擊紅色區域,事件最終會傳遞到CircleButton,CircleButton能夠響應事件,最終事件就由CircleButton響應了。同時點擊紅色方框以外的非TabBar區域的情況下,因為TabBar無法響應事件,會按照預期由TableView響應。代碼如下:
//TabBar - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {//將觸摸點坐標轉換到在CircleButton上的坐標CGPoint pointTemp = [self convertPoint:point toView:_CircleButton];//若觸摸點在CricleButton上則返回YESif ([_CircleButton pointInside:pointTemp withEvent:event]) {return YES;}//否則返回默認的操作return [super pointInside:point withEvent:event]; }這樣一來,點擊紅色方框區域的按鈕就有效了。
現在第二個問題也可以回答了。另外項目中如遇到不按常理出牌的事件響應需求,相信你也應該可以應對了。
事件的響應及在響應鏈中的傳遞
經歷Hit-Testing后,UIApplication已經知道事件的最佳響應者是誰了,接下來要做的事情就是:
事件響應的前奏
因為最佳響應者具有最高的事件響應優先級,因此UIApplication會先將事件傳遞給它供其響應。首先,UIApplication將事件通過 sendEvent: 傳遞給事件所屬的window,window同樣通過 sendEvent: 再將事件傳遞給hit-tested view,即最佳響應者。過程如下:
UIApplication ——> UIWindow ——> hit-tested view以尋找事件的最佳響應者一節中點擊視圖E為例,在EView的 touchesBegan:withEvent: 上斷點查看調用棧就能看清這一過程:
touchesBegan調用棧
那么問題又來了。這個過程中,假如應用中存在多個window對象,UIApplication是怎么知道要把事件傳給哪個window的?window又是怎么知道哪個視圖才是最佳響應者的呢?
其實簡單思考一下,這兩個過程都是傳遞事件的過程,涉及的方法都是 sendEvent: ,而該方法的參數(UIEvent對象)是唯一貫穿整個經過的線索,那么就可以大膽猜測必然是該觸摸事件對象上綁定了這些信息。事實上之前在介紹UITouch的時候就說過touch對象保存了觸摸所屬的window及view,而event對象又綁定了touch對象,如此一來,是不是就說得通了。要是不信的話,那就自定義一個Window類,重寫 sendEvent: 方法,捕捉該方法調用時參數event的狀態,答案就顯而易見了。
sendEvent
至于這兩個屬性是什么時候綁定到touch對象上的,必然是在hit-testing的過程中唄,仔細想想hit-testing干的不就是這個事兒嗎~
事件的響應
前面介紹UIResponder的時候說過,每個響應者必定都是UIResponder對象,通過4個響應觸摸事件的方法來響應事件。每個UIResponder對象默認都已經實現了這4個方法,但是默認不對事件做任何處理,單純只是將事件沿著響應鏈傳遞。若要截獲事件進行自定義的響應操作,就要重寫相關的方法。例如,通過重寫 touchesMoved: withEvent: 方法實現簡單的視圖拖動。
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;每個響應觸摸事件的方法都會接收兩個參數,分別對應觸摸對象集合和事件對象。通過監聽觸摸對象中保存的觸摸點位置的變動,可以時時修改視圖的位置。視圖(UIView)作為響應者對象,本身已經實現了 touchesMoved: withEvent: 方法,因此要創建一個自定義視圖(繼承自UIView),重寫該方法。
//MovedView //重寫touchesMoved方法(觸摸滑動過程中持續調用) - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {//獲取觸摸對象UITouch *touch = [touches anyObject];//獲取前一個觸摸點位置CGPoint prePoint = [touch previousLocationInView:self];//獲取當前觸摸點位置CGPoint curPoint = [touch locationInView:self];//計算偏移量CGFloat offsetX = curPoint.x - prePoint.x;CGFloat offsetY = curPoint.y - prePoint.y;//相對之前的位置偏移視圖self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY); }每個響應者都有權決定是否執行對事件的響應,只要重寫相關的觸摸事件方法即可。
事件的傳遞(響應鏈)
前面一直在提最佳響應者,之所以稱之為“最佳”,是因為其具備響應事件的最高優先權(響應鏈頂端的男人)。最佳響應者首先接收到事件,然后便擁有了對事件的絕對控制權:即它可以選擇獨吞這個事件,也可以將這個事件往下傳遞給其他響應者,這個由響應者構成的視圖鏈就稱之為響應鏈。
需要注意的是,上一節中也說到了事件的傳遞,與此處所說的事件的傳遞有本質區別。上一節所說的事件傳遞的目的是為了尋找事件的最佳響應者,是自下而上的傳遞;而這里的事件傳遞目的是響應者做出對事件的響應,這個過程是自上而下的。前者為“尋找”,后者為“響應”。
響應者對于事件的操作方式:
響應者對于事件的攔截以及傳遞都是通過 touchesBegan:withEvent: 方法控制的,該方法的默認實現是將事件沿著默認的響應鏈往下傳遞。
響應者對于接收到的事件有3種操作:
- 不攔截,默認操作
事件會自動沿著默認的響應鏈往下傳遞 - 攔截,不再往下分發事件
重寫 touchesBegan:withEvent: 進行事件處理,不調用父類的 touchesBegan:withEvent: - 攔截,繼續往下分發事件
重寫 touchesBegan:withEvent: 進行事件處理,同時調用父類的 touchesBegan:withEvent: 將事件往下傳遞
響應鏈中的事件傳遞規則:
每一個響應者對象(UIResponder對象)都有一個 nextResponder 方法,用于獲取響應鏈中當前對象的下一個響應者。因此,一旦事件的最佳響應者確定了,這個事件所處的響應鏈就確定了。
對于響應者對象,默認的 nextResponder 實現如下:
- UIView
若視圖是控制器的根視圖,則其nextResponder為控制器對象;否則,其nextResponder為父視圖。 - UIViewController
若控制器的視圖是window的根視圖,則其nextResponder為窗口對象;若控制器是從別的控制器present出來的,則其nextResponder為presenting view controller。 - UIWindow
nextResponder為UIApplication對象。 - UIApplication
若當前應用的app delegate是一個UIResponder對象,且不是UIView、UIViewController或app本身,則UIApplication的nextResponder為app delegate。
responderChain
上圖是官網對于響應鏈的示例展示,若觸摸發生在UITextField上,則事件的傳遞順序是:
- UITextField ——> UIView ——> UIView ——> UIViewController ——> UIWindow ——> UIApplication ——> UIApplicationDelegation
圖中虛線箭頭是指若該UIView是作為UIViewController根視圖存在的,則其nextResponder為UIViewController對象;若是直接add在UIWindow上的,則其nextResponder為UIWindow對象。
可以用以下方式打印一個響應鏈中的每一個響應對象,在最佳響應者的 touchBegin:withEvent: 方法中調用即可(別忘了調用父類的方法)
- (void)printResponderChain {UIResponder *responder = self;printf("%s",[NSStringFromClass([responder class]) UTF8String]);while (responder.nextResponder) {responder = responder.nextResponder;printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]);} }以上一節原型按鈕的案例為例,重寫CircleButton的 touchBegin:withEvent:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {[self printResponderChain];[super touchesBegan:touches withEvent:event]; }點擊原型按鈕的任意區域,打印出的完整響應鏈如下:
CircleButton –> CustomeTabBar –> UIView –> UIViewController –> UIViewControllerWrapperView –> UINavigationTransitionView –> UILayoutContainerView –> UINavigationController –> UIWindow –> UIApplication –> AppDelegate
另外如果有需要,完全可以重寫響應者的 nextResponder 方法來自定義響應鏈。
現在,第三個問題也解決了。
事件的三徒弟UIResponder、UIGestureRecognizer、UIControl
iOS中,除了UIResponder能夠響應事件,手勢識別器、UIControl同樣具備對事件的處理能力。當這幾者同時存在于某一場景下的時候,事件又會有怎樣的歸宿呢?
拋磚引玉
場景界面如圖:
手勢沖突場景
代碼不能再簡單:
- (void)viewDidLoad {[super viewDidLoad];//底部是一個綁定了單擊手勢的backViewUITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTapView)];[_backView addGestureRecognizer:tap];//上面是一個常規的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。what??點不動??點歪了嗎??再點,還是沒反應!!我試著短按了一小會兒cell,依舊沒反應!!我不死心,長按了一會兒,didSelectRowAtIndexPath終于調了,還算給點面子 - -。然后我又點了下面的button,沒有任何問題。but what ??
為了搞清楚狀況,我自定義了相關的控件類,均重寫了4個響應觸摸事件的方法以打印日志(每個重寫的觸摸事件方法都調用了父類的方法以保證事件默認傳遞邏輯)。
觀察各種情況下的日志現象:
現象一 快速點擊cell
backview taped現象二 短按cell
-[GLTableView touchesBegan:withEvent:] backview taped -[GLTableView touchesCancelled:withEvent:]現象三 長按cell
-[GLTableView touchesBegan:withEvent:] -[GLTableView touchesEnded:withEvent:] cell selected!現象四 點擊button
-[GLButton touchesBegan:withEvent:] -[GLButton touchesEnded:withEvent:] button clicked!如果上面的現象依舊能讓你舒心地抿上一口咖啡,那么恭喜你,本節的內容已經不適合你了。如果覺得一臉懵逼,那就繼續往下看吧~
二師兄—手勢識別器
關于手勢識別器即 UIGestureRecognizer 本身的使用不是本文要所討論的內容,按下不表。此處要探討的是:手勢識別器與UIResponder的聯系。
事實上,手勢分為離散型手勢(discrete gestures)和持續型手勢(continuous gesture)。系統提供的離散型手勢包括點按手勢(UITapGestureRecognizer)和輕掃手勢(UISwipeGestureRecognizer),其余均為持續型手勢。
兩者主要區別在于狀態變化過程:
離散型:
識別成功:Possible —> Recognized
識別失敗:Possible —> Failed持續型:
完整識別:Possible —> Began —> [Changed] —> Ended
不完整識別:Possible —> Began —> [Changed] —> Cancel
離散型手勢
先拋開上面的場景,看一個簡單的demo。
離散型手勢場景
控制器的視圖上add了一個View記為YellowView,并綁定了一個單擊手勢識別器。
// LXFViewController - (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了對觸摸事件的響應,而正常應當是觸摸結束后,YellowView的 touchesEnded:withEvent: 的方法被調用才對。另外,期間還執行了手勢識別器綁定的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, delaysTouchesEnded properties.
大致理解是,Window在將事件傳遞給hit-tested view之前,會先將事件傳遞給相關的手勢識別器并由手勢識別器優先識別。若手勢識別器成功識別了事件,就會取消hit-tested view對事件的響應;若手勢識別器沒能識別事件,hit-tested view才完全接手事件的響應權。
一句話概括:手勢識別器比UIResponder具有更高的事件響應優先級!!
按照這個解釋,Window在將事件傳遞給hit-tested view即YellowView之前,先傳遞給了控制器根視圖上的手勢識別器。手勢識別器成功識別了該事件,通知Application取消YellowView對事件的響應。
然而看日志,卻是YellowView的 touchesBegan:withEvent: 先調用了,既然手勢識別器先響應,不應該上面的action先執行嗎,這又怎么解釋?事實上這個認知是錯誤的。手勢識別器的action的調用時機(即此處的 actionTap)并不是手勢識別器接收到事件的時機,而是手勢識別器成功識別事件后的時機,即手勢識別器的狀態變為UIGestureRecognizerStateRecognized。因此從該日志中并不能看出事件是優先傳遞給手勢識別器的,那該怎么證明Window先將事件傳遞給了手勢識別器?
要解決這個問題,只要知道手勢識別器是如何接收事件的,然后在接收事件的方法中打印日志對比調用時間先后即可。說起來你可能不信,手勢識別器對于事件的響應也是通過這4個熟悉的方法來實現的。
- (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;需要注意的是,雖然手勢識別器通過這幾個方法來響應事件,但它并不是UIResponder的子類,相關的方法聲明在 UIGestureRecognizerSubclass.h 中。
這樣一來,我們便可以自定義一個單擊手勢識別器的類,重寫這幾個方法來監聽手勢識別器接收事件的時機。創建一個UITapGestureRecognizer的子類,重寫響應事件的方法,每個方法中調用父類的實現,并替換demo中的手勢識別器。另外需要在.m文件中引入 import <UIKit/UIGestureRecognizerSubclass.h> ,因為相關方法聲明在該頭文件中。
// LXFTapGestureRecognizer (繼承自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]; }現在,再次點擊YellowView,日志如下:
-[LXFTapGestureRecognizer touchesBegan:withEvent:] -[YellowView touchesBegan:withEvent:] -[LXFTapGestureRecognizer touchesEnded:withEvent:] View Taped -[YellowView touchesCancelled:withEvent:]很明顯,確實是手勢識別器先接收到了事件。之后手勢識別器成功識別了手勢,執行了action,再由Application取消了YellowView對事件的響應。
Window怎么知道要把事件傳遞給哪些手勢識別器?
之前探討過Application怎么知道要把event傳遞給哪個Window,以及Window怎么知道要把event傳遞給哪個hit-tested view的問題,答案是這些信息都保存在event所綁定的touch對象上。手勢識別器也是一樣的,event綁定的touch對象上維護了一個手勢識別器數組,里面的手勢識別器毫無疑問是在hit-testing的過程中收集的。打個斷點看一下touch上綁定的手勢識別器數組:
手勢識別器捕捉
Window先將事件傳遞給這些手勢識別器,再傳給hit-tested view。一旦有手勢識別器成功識別了手勢,Application就會取消hit-tested view對事件的響應。
持續型手勢
將上面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上執行一次滑動:
持續型手勢場景
日志打印如下:
-[YellowView touchesBegan:withEvent:] -[YellowView touchesMoved:withEvent:] -[YellowView touchesMoved:withEvent:] -[YellowView touchesMoved:withEvent:] View panned -[YellowView touchesCancelled:withEvent:] View panned View panned View panned ...在一開始滑動的過程中,手勢識別器處在識別手勢階段,滑動產生的連續事件既會傳遞給手勢識別器又會傳遞給YellowView,因此YellowView的 touchesMoved:withEvent: 在開始一段時間內會持續調用;當手勢識別器成功識別了該滑動手勢時,手勢識別器的action開始調用,同時通知Application取消YellowView對事件的響應。之后僅由滑動手勢識別器接收事件并響應,YellowView不再接收事件。
另外,在滑動的過程中,若手勢識別器未能識別手勢,則事件在觸摸滑動過程中會一直傳遞給hit-tested view,直到觸摸結束。讀者可自行驗證。
手勢識別器的3個屬性
@property(nonatomic) BOOL cancelsTouchesInView; @property(nonatomic) BOOL delaysTouchesBegan; @property(nonatomic) BOOL delaysTouchesEnded;先總結一下手勢識別器與UIResponder對于事件響應的聯系:
當觸摸發生或者觸摸的狀態發生變化時,Window都會傳遞事件尋求響應。
- Window先將綁定了觸摸對象的事件傳遞給觸摸對象上綁定的手勢識別器,再發送給觸摸對象對應的hit-tested view。
- 手勢識別器識別手勢期間,若觸摸對象的觸摸狀態發生變化,事件都是先發送給手勢識別器再發送給hit-test view。
- 手勢識別器若成功識別了手勢,則通知Application取消hit-tested view對于事件的響應,并停止向hit-tested view發送事件;
- 若手勢識別器未能識別手勢,而此時觸摸并未結束,則停止向手勢識別器發送事件,僅向hit-test view發送事件。
- 若手勢識別器未能識別手勢,且此時觸摸已經結束,則向hit-tested view發送end狀態的touch事件以停止對事件的響應。
cancelsTouchesInView
默認為YES。表示當手勢識別器成功識別了手勢之后,會通知Application取消響應鏈對事件的響應,并不再傳遞事件給hit-test view。若設置成NO,表示手勢識別成功后不取消響應鏈對事件的響應,事件依舊會傳遞給hit-test view。
demo中設置: 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也會依舊發送事件給YellowView。
delaysTouchesBegan
默認為NO。默認情況下手勢識別器在識別手勢期間,當觸摸狀態發生改變時,Application都會將事件傳遞給手勢識別器和hit-tested view;若設置成YES,則表示手勢識別器在識別手勢期間,截斷事件,即不會將事件發送給hit-tested view。
設置 pan.delaysTouchesBegan = YES
日志如下:
View panned View panned View panned View panned ...因為滑動手勢識別器在識別期間,事件不會傳遞給YellowView,因此期間YellowView的 touchesBegan:withEvent: 和 touchesMoved:withEvent: 都不會被調用;而后滑動手勢識別器成功識別了手勢,也就獨吞了事件,不會再傳遞給YellowView。因此只打印了手勢識別器成功識別手勢后的action調用。
delaysTouchesEnded
默認為NO。默認情況下當手勢識別器未能識別手勢時,若此時觸摸已經結束,則會立即通知Application發送狀態為end的touch事件給hit-tested view以調用 touchesEnded:withEvent: 結束事件響應;若設置為YES,則會在手勢識別失敗時,延遲一小段時間(0.15s)再調用響應者的 touchesEnded:withEvent:。
總結:手勢識別器比響應鏈具有更高的事件響應優先級。
大師兄—UIControl
UIControl是系統提供的能夠以target-action模式處理觸摸事件的控件,iOS中UIButton、UISegmentedControl、UISwitch等控件都是UIControl的子類。當UIControl跟蹤到觸摸事件時,會向其上添加的target發送事件以執行action。值得注意的是,UIConotrol是UIView的子類,因此本身也具備UIResponder應有的身份。
關于UIControl,此處介紹兩點:
target-action
- target:處理交互事件的對象
- action:處理交互事件的方式
UIControl作為能夠響應事件的控件,必然也需要待事件交互符合條件時才去響應,因此也會跟蹤事件發生的過程。不同于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只能接收單點觸控,因此接收的參數是單個UITouch對象。這幾個方法的職能也和UIResponder一致,用來跟蹤觸摸的開始、滑動、結束、取消。不過,UIControl本身也是UIResponder,因此同樣有 touches 系列的4個方法。事實上,UIControl的 Tracking 系列方法是在 touch 系列方法內部調用的。比如 beginTrackingWithTouch 是在 touchesBegan 方法內部調用的, 因此它雖然也是UIResponder,但 touches 系列方法的默認實現和UIResponder本類還是有區別的。
當UIControl跟蹤事件的過程中,識別出事件交互符合響應條件,就會觸發target-action進行響應。UIControl控件通過 addTarget:action:forControlEvents: 添加事件處理的target和action,當事件發生時,UIControl通知target執行對應的action。說是“通知”其實很籠統,事實上這里有個action傳遞的過程。當UIControl監聽到需要處理的交互事件時,會調用 sendAction:to:forEvent: 將target、action以及event對象發送給全局應用,Application對象再通過 sendAction:to:from:forEvent: 向target發送action。
target-action過程
因此,可以通過重寫UIControl的 sendAction:to:forEvent: 或 sendAction:to:from:forEvent: 自定義事件執行的target及action。
另外,若不指定target,即 addTarget:action:forControlEvents: 時target傳空,那么當事件發生時,Application會在響應鏈上從上往下尋找能響應action的對象。官方說明如下:
If you specify nil for the target object, the control searches the responder chain for an object that defines the specified action method.
觸摸事件優先級
當原本關系已經錯綜復雜的UIGestureRecognizer和UIResponder之間又冒出一個UIControl,又會摩擦出什么樣的火花呢?
In iOS 6.0 and later, default control actions prevent overlapping gesture recognizer behavior. For example, the default action for a button is a single tap. If you have a single tap gesture recognizer attached to a button’s parent view, and the user taps the button, then the button’s action method receives the touch event instead of the gesture recognizer.
簡單理解:UIControl會阻止父視圖上的手勢識別器行為,也就是UIControl處理事件的優先級比UIGestureRecognizer高,但前提是相比于父視圖上的手勢識別器。
UIControl測試場景
-
預置場景:在BlueView上添加一個button,同時給button添加一個target-action事件。
- 示例一:在BlueView上添加點擊手勢識別器
- 示例二:在button上添加手勢識別器
操作方式:單擊button
-
測試結果:示例一中,button的target-action響應了單擊事件;示例二中,BlueView上的手勢識別器響應了事件。過程日志打印如下:
//示例一 -[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 手勢觸發 -[CLButton touchesCancelled:withEvent:] -[CLButton cancelTrackingWithEvent:] 原因分析:點擊button后,事件先傳遞給手勢識別器,再傳遞給作為hit-tested view存在的button(UIControl本身也是UIResponder,這一過程和普通事件響應者無異)。示例一中,由于button阻止了父視圖BlueView中的手勢識別器的識別,導致手勢識別器識別失敗(狀態為failed 枚舉值為5),button完全接手了事件的響應權,事件最終由button響應;示例二中,button未阻止其本身綁定的手勢識別器的識別,因此手勢識別器先識別手勢并識別成功(狀態為ended 枚舉值為3),而后通知Application取消響應鏈對事件的響應,因為 touchesCancelled 被調用,同時 cancelTrackingWithEvent 跟著調用,因此button的target-action得不到執行。
其他:經測試,若示例一中的手勢識別器設置 cancelsTouchesInView 為NO,手勢識別器和button都能響應事件。也就是說這種情況下,button不會阻止父視圖中手勢識別器的識別。
結論:UIControl比其父視圖上的手勢識別器具有更高的事件響應優先級。
TODO:
上述過程中,手勢識別器在執行touchesEnded時是根據什么將狀態置為ended還是failed的?即根據什么判斷應當識別成功還是識別失敗?
撥云見日
現在,把膠卷回放到本章節開頭的場景。給你一杯咖啡的時間看看能不能解釋得通那幾個現象了,不說了泡咖啡去了…
我肥來了!
先看現象二,短按 cell無法響應,日志如下:
-[GLTableView touchesBegan:withEvent:] backview taped -[GLTableView touchesCancelled:withEvent:]這個日志和上面離散型手勢Demo中打印的日志完全一致。短按后,BackView上的手勢識別器先接收到事件,之后事件傳遞給hit-tested view,作為響應者鏈中一員的GLTableView的 touchesBegan:withEvent: 被調用;而后手勢識別器成功識別了點擊事件,action執行,同時通知Application取消響應鏈中的事件響應,GLTableView的 touchesCancelled:withEvent: 被調用。
因為事件被取消了,因此Cell無法響應點擊。
再看現象三,長按cell能夠響應,日志如下:
-[GLTableView touchesBegan:withEvent:] -[GLTableView touchesEnded:withEvent:] cell selected!長按的過程中,一開始事件同樣被傳遞給手勢識別器和hit-tested view,作為響應鏈中一員的GLTableView的 touchesBegan:withEvent: 被調用;此后在長按的過程中,手勢識別器一直在識別手勢,直到一定時間后手勢識別失敗,才將事件的響應權完全交給響應鏈。當觸摸結束的時候,GLTableView的 touchesEnded:withEvent: 被調用,同時Cell響應了點擊。
OK,現在回到現象一。按照之前的分析,快速點擊cell,講道理不管是表現還是日志都應該和現象二一致才對。然而日志僅僅打印了手勢識別器的action執行結果。分析一下原因:GLTableView的 touchesBegan 沒有調用,說明事件沒有傳遞給hit-tested view。那只有一種可能,就是事件被某個手勢識別器攔截了。目前已知的手勢識別器攔截事件的方法,就是設置 delaysTouchesBegan 為YES,在手勢識別器未識別完成的情況下不會將事件傳遞給hit-tested view。然后事實上并沒有進行這樣的設置,那么問題可能出在別的手勢識別器上。
Window的 sendEvent: 打個斷點查看event上的touch對象維護的手勢識別器數組:
ScrollView延遲發送事件
捕獲可疑對象:UIScrollViewDelayedTouchesBeganGestureRecognizer ,光看名字就覺得這貨脫不了干系。從類名上猜測,這個手勢識別器大概會延遲事件向響應鏈的傳遞。github上找到了該私有類的頭文件:
@interface UIScrollViewDelayedTouchesBeganGestureRecognizer : UIGestureRecognizer {UIView<UIScrollViewDelayedTouchesBeganGestureRecognizerClient> * _client;struct CGPoint { float x; float y; } _startSceneReferenceLocation;UIDelayedAction * _touchDelay; } - (void).cxx_destruct; - (id)_clientView; - (void)_resetGestureRecognizer; - (void)clearTimer; - (void)dealloc; - (void)sendDelayedTouches; - (void)sendTouchesShouldBeginForDelayedTouches:(id)arg1; - (void)sendTouchesShouldBeginForTouches:(id)arg1 withEvent:(id)arg2; - (void)touchesBegan:(id)arg1 withEvent:(id)arg2; - (void)touchesCancelled:(id)arg1 withEvent:(id)arg2; - (void)touchesEnded:(id)arg1 withEvent:(id)arg2; - (void)touchesMoved:(id)arg1 withEvent:(id)arg2; @end有一個_touchDelay變量,大概是用來控制延遲事件發送的。另外,方法列表里有個 sendTouchesShouldBeginForDelayedTouches: 方法,聽名字似乎是在一段時間延遲后向響應鏈傳遞事件用的。為一探究竟,我創建了一個類hook了這個方法:
//TouchEventHook.m + (void)load{Class aClass = objc_getClass("UIScrollViewDelayedTouchesBeganGestureRecognizer");SEL sel = @selector(hook_sendTouchesShouldBeginForDelayedTouches:);Method method = class_getClassMethod([self class], sel);class_addMethod(aClass, sel, class_getMethodImplementation([self class], sel), method_getTypeEncoding(method));exchangeMethod(aClass, @selector(sendTouchesShouldBeginForDelayedTouches:), sel); }- (void)hook_sendTouchesShouldBeginForDelayedTouches:(id)arg1{[self hook_sendTouchesShouldBeginForDelayedTouches:arg1]; }void exchangeMethod(Class aClass, SEL oldSEL, SEL newSEL) {Method oldMethod = class_getInstanceMethod(aClass, oldSEL);Method newMethod = class_getInstanceMethod(aClass, newSEL);method_exchangeImplementations(oldMethod, newMethod); }斷點看一下點擊cell后 hook_sendTouchesShouldBeginForDelayedTouches: 調用時的信息:
延遲的本質
可以看到這個手勢識別器的 _touchDelay 變量中,保存了一個計時器,以及一個長得很像延遲時間間隔的變量m_delay。現在,可以推測該手勢識別器截斷了事件并延遲0.15s才發送給hit-tested view。為驗證猜測,我分別在Window的 sendEvent: ,hook_sendTouchesShouldBeginForDelayedTouches: 以及TableView的 touchesBegan: 中打印時間戳,若猜測成立,則應當前兩者的調用時間相差0.15s左右,后兩者的調用時間很接近。短按Cell后打印結果如下(不能快速點擊,否則還沒過延遲時間觸摸就結束了,無法驗證猜測):
-[GLWindow sendEvent:]調用時間戳 : 525252194779.07ms -[TouchEventHook hook_sendTouchesShouldBeginForDelayedTouches:]調用時間戳 : 525252194930.91ms -[TouchEventHook hook_sendTouchesShouldBeginForDelayedTouches:]調用時間戳 : 525252194931.24ms -[GLTableView touchesBegan:withEvent:]調用時間戳 : 525252194931.76ms因為有兩個 UIScrollViewDelayedTouchesBeganGestureRecognizer,所以 hook_sendTouchesShouldBeginForDelayedTouches 調了兩次,兩次的時間很接近。可以看到,結果完全符合猜測。
這樣就都解釋得通了。現象一由于點擊后,UIScrollViewDelayedTouchesBeganGestureRecognizer 攔截了事件并延遲了0.15s發送。又因為點擊時間比0.15s短,在發送事件前觸摸就結束了,因此事件沒有傳遞到hit-tested view,導致TableView的 touchBegin 沒有調用。而現象二,由于短按的時間超過了0.15s,手勢識別器攔截了事件并經過0.15s后,觸摸還未結束,于是將事件傳遞給了hit-tested view,使得TableView接收到了事件。因此現象二的日志雖然和離散型手勢Demo中的日志一致,但實際上前者的hit-tested view是在觸摸后延遲了約0.15s左右才接收到觸摸事件的。
至于現象四 ,你現在應該已經覺得理所當然了才對。
總結
- 觸摸發生時,系統內核生成觸摸事件,先由IOKit處理封裝成IOHIDEvent對象,通過IPC傳遞給系統進程SpringBoard,而后再傳遞給前臺APP處理。
- 事件傳遞到APP內部時被封裝成開發者可見的UIEvent對象,先經過hit-testing尋找第一響應者,而后由Window對象將事件傳遞給hit-tested view,并開始在響應鏈上的傳遞。
- UIRespnder、UIGestureRecognizer、UIControl,籠統地講,事件響應優先級依次遞增。
參考資料
總結
以上是生活随笔為你收集整理的ios 触摸事件 学习的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 博弈论课程学习---浙大蒋文华
- 下一篇: BZOJ 4719: [Noip2016