歡迎光臨
每天分享高質量文章

iOS觸摸事件全家桶

作者:Lotheve

鏈接:https://www.jianshu.com/p/c294d1bd963d

好奇觸摸事件是如何從屏幕轉移到APP內的?

困惑於Cell怎麼突然不能點擊了?

糾結於如何實現這個奇葩響應需求?

亦或是已經被響應鏈、手勢、target-action這一系列響應觸摸事件的方式折騰到不會打Hello World?

現在 是時候帶你上分了~ (強行YY完畢)

本文主要講解iOS觸摸事件的一系列機制,涉及的問題大致包括:

  • 觸摸事件由觸屏生成後如何傳遞到當前應用?

  • 應用接收觸摸事件後如何尋找最佳響應者?實現原理?

  • 觸摸事件如何沿著響應鏈流動?

  • 響應鏈、手勢識別器、UIControl之間對於觸摸事件的響應有著什麼樣的瓜葛?


tips: iOS中的事件除了觸摸事件,還包括加速計事件、遠程控制事件。由於兩者不在本文討論範疇,因此文中所說事件均特指觸摸事件。

事件的生命周期


當指尖觸碰屏幕的那一刻,一個觸摸事件就在系統中生成了。經過IPC行程間通信,事件最終被傳遞到了合適的應用。在應用內歷經峰迴路轉的奇幻之旅後,最終被釋放。大致經過如下圖:

觸摸事件流動過程 圖片來源(http://qingmo.me/2017/03/04/FlowOfUITouch/)


系統響應階段


1、手指觸碰屏幕,屏幕感應到觸碰後,將事件交由IOKit處理。

2、IOKit將觸摸事件封裝成一個IOHIDEvent物件,並通過mach port傳遞給SpringBoad行程。

mach port 行程端口,各行程之間通過它進行通信。

SpringBoad.app 是一個系統行程,可以理解為桌面系統,可以統一管理和分發系統接收到的觸摸事件。

3、SpringBoard行程因接收到觸摸事件,觸發了主執行緒runloop的source1事件源的回呼。

此時SpringBoard會根據當前桌面的狀態,判斷應該由誰處理此次觸摸事件。因為事件發生時,你可能正在桌面上翻頁,也可能正在刷微博。若是前者(即前臺無APP運行),則觸發SpringBoard本身主執行緒runloop的source0事件源的回呼,將事件交由桌面系統去消耗;若是後者(即有app正在前臺運行),則將觸摸事件通過IPC傳遞給前臺APP行程,接下來的事情便是APP內部對於觸摸事件的響應了。

APP響應階段


1、APP行程的mach port接受到SpringBoard行程傳遞來的觸摸事件,主執行緒的runloop被喚醒,觸發了source1回呼。


2、source1回呼又觸發了一個source0回呼,將接收到的IOHIDEvent物件封裝成UIEvent物件,此時APP將正式開始對於觸摸事件的響應。


3、source0回呼內部將觸摸事件添加到UIApplication物件的事件佇列中。事件出隊後,UIApplication開始一個尋找最佳響應者的過程,這個過程又稱hit-testing,細節將在[尋找事件的最佳響應者]一節闡述。另外,此處開始便是與我們平時開發相關的工作了。


4、尋找到最佳響應者後,接下來的事情便是事件在響應鏈中的傳遞及響應了,關於響應鏈相關的內容詳見[事件的響應及在響應鏈中的傳遞]一節。事實上,事件除了被響應者消耗,還能被手勢識別器或是target-action樣式捕捉並消耗掉。其中涉及對觸摸事件的響應優先級,詳見[事件的三徒弟UIResponder、UIGestureRecognizer、UIControl]一節。


5、觸摸事件歷經坎坷後要麼被某個響應物件捕獲後釋放,要麼致死也沒能找到能夠響應的物件,最終釋放。至此,這個觸摸事件的使命就算終結了。runloop若沒有其他事件需要處理,也將重歸於眠,等待新的事件到來後喚醒。


現在,你可以回答第一個問題了。觸摸事件從觸屏產生後,由IOKit將觸摸事件傳遞給SpringBoard行程,再由SpringBoard分發給當前前臺APP處理。

觸摸、事件、響應者


說了那麼多,到底什麼是觸摸、什麼是事件、什麼是響應者?先簡單科普一下。

UITouch

源起觸摸

  • 一個手指一次觸摸屏幕,就對應生成一個UITouch物件。多個手指同時觸摸,生成多個UITouch物件。

  • 多個手指先後觸摸,系統會根據觸摸的位置判斷是否更新同一個UITouch物件。若兩個手指一前一後觸摸同一個位置(即雙擊),那麼第一次觸摸時生成一個UITouch物件,第二次觸摸更新這個UITouch物件(UITouch物件的 tap count 屬性值從1變成2);若兩個手指一前一後觸摸的位置不同,將會生成兩個UITouch物件,兩者之間沒有聯繫。

  • 每個UITouch物件記錄了觸摸的一些信息,包括觸摸時間、位置、階段、所處的視圖、視窗等信息。


//觸摸的各個階段狀態 
//例如當手指移動時,會更新phase屬性到UITouchPhaseMoved;手指離屏後,更新到UITouchPhaseEnded
typedef NS_ENUM(NSIntegerUITouchPhase) {
    UITouchPhaseBegan,             // whenever a finger touches the surface.
    UITouchPhaseMoved,             // whenever a finger moves on the surface.
    UITouchPhaseStationary,        // whenever a finger is touching the surface but hasn't moved since the previous event.
    UITouchPhaseEnded,             // whenever a finger leaves the surface.
    UITouchPhaseCancelled,         // whenever a touch doesn't end but we need to stop tracking (e.g. putting device to face)
};

手指離開屏幕一段時間後,確定該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。

本節要探討的問題是:

1、應用接收到事件後,如何尋找最佳響應者?底層如何實現?

2、尋找最佳響應者過程中事件的攔截。


事件自下而上的傳遞


應用接收到事件後先將其置入事件佇列中以等待處理。出隊後,application首先將事件傳遞給當前應用最後顯示的視窗(UIWindow)詢問其能否響應事件。若視窗能響應事件,則傳遞給子視圖詢問是否能響應,子視圖若能響應則繼續詢問子視圖。子視圖詢問的順序是優先詢問後添加的子視圖,即子視圖陣列中靠後的視圖。事件傳遞順序如下:

UIApplication ——> UIWindow ——> 子視圖 ——> … ——> 子視圖


事實上把UIWindow也看成是視圖即可,這樣整個傳遞過程就是一個遞迴詢問子視圖能否響應事件過程,且後添加的子視圖優先級高(對於window而言就是後顯示的window優先級高)。

具體流程如下:

1、UIApplication首先將事件傳遞給視窗物件(UIWindow),若存在多個視窗,則優先詢問後顯示的視窗。

2、若視窗不能響應事件,則將事件傳遞其他視窗;若視窗能響應事件,則從後往前詢問視窗的子視圖。

3、重覆步驟2。即視圖若不能響應,則將事件傳遞給上一個同級子視圖;若能響應,則從後往前詢問當前視圖的子視圖。

4、視圖若沒有能響應的子視圖了,則自身就是最合適的響應者。


示例:

視圖層級如下(同一層級的視圖越在下麵,表示越後添加):

A
├── B
│   └── D
└── C
    ├── E
    └── F

現在假設在E視圖所處的屏幕位置觸發一個觸摸,應用接收到這個觸摸事件事件後,先將事件傳遞給UIWindow,然後自下而上開始在子視圖中尋找最佳響應者。事件傳遞的順序如下所示:


1、UIWindow將事件傳遞給其子視圖A

2、A判斷自身能響應該事件,繼續將事件傳遞給C(因為視圖C比視圖B後添加,因此優先傳給C)。

3、C判斷自身能響應事件,繼續將事件傳遞給F(同理F比E後添加)。

4、F判斷自身不能響應事件,C又將事件傳遞給E。

5、E判斷自身能響應事件,同時E已經沒有子視圖,因此最終E就是最佳響應者。


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.01return nil
    //觸摸點若不在當前視圖上則無法響應事件
    if ([self pointInside:point withEvent:event] == NOreturn 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上則傳回YES
    if ([_CircleButton pointInside:pointTemp withEvent:event]) {
        return YES;
    }
    //否則傳回預設的操作
    return [super pointInside:point withEvent:event];
}

這樣一來,點擊紅色方框區域的按鈕就有效了。

現在第二個問題也可以回答了。另外專案中如遇到不按常理出牌的事件響應需求,相信你也應該可以應對了。

事件的響應及在響應鏈中的傳遞


經歷Hit-Testing後,UIApplication已經知道事件的最佳響應者是誰了,接下來要做的事情就是:

1、將事件傳遞給最佳響應者響應

2、事件沿著響應鏈傳遞


事件響應的前奏


因為最佳響應者具有最高的事件響應優先級,因此UIApplication會先將事件傳遞給它供其響應。首先,UIApplication將事件通過 sendEvent: 傳遞給事件所屬的window,window同樣通過 sendEvent: 再將事件傳遞給hit-tested view,即最佳響應者。過程如下:

UIApplication ——> UIWindow ——> hit-tested view


以尋找事件的最佳響應者一節中點擊視圖E為例,在EView的 touchesBegan:withEvent: 上斷點查看呼叫棧就能看清這一過程:

那麼問題又來了。這個過程中,假如應用中存在多個window物件,UIApplication是怎麼知道要把事件傳給哪個window的?window又是怎麼知道哪個視圖才是最佳響應者的呢?

其實簡單思考一下,這兩個過程都是傳遞事件的過程,涉及的方法都是 sendEvent: ,而該方法的引數(UIEvent物件)是唯一貫穿整個經過的線索,那麼就可以大膽猜測必然是該觸摸事件物件上系結了這些信息。事實上之前在介紹UITouch的時候就說過touch物件儲存了觸摸所屬的window及view,而event物件又系結了touch物件,如此一來,是不是就說得通了。要是不信的話,那就自定義一個Window類,重寫 sendEvent: 方法,捕捉該方法呼叫時引數event的狀態,答案就顯而易見了。

至於這兩個屬性是什麼時候系結到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。


上圖是官網對於響應鏈的示例展示,若觸摸發生在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];
    //底部是一個系結了單擊手勢的backView
    UITapGestureRecognizer *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 ,因為相關方法宣告在該頭檔案中。

// 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

預設為YES。當手勢識別失敗時,若此時觸摸已經結束,會延遲一小段時間(0.15s)再呼叫響應者的 touchesEnded:withEvent:;若設置成NO,則在手勢識別失敗時會立即通知Application發送狀態為end的touch事件給hit-tested view以呼叫 touchesEnded:withEvent: 結束事件響應。

總結:手勢識別器比響應鏈具有更高的事件響應優先級。

大師兄—UIControl


UIControl是系統提供的能夠以target-action樣式處理觸摸事件的控制元件,iOS中UIButton、UISegmentedControl、UISwitch等控制元件都是UIControl的子類。當UIControl跟蹤到觸摸事件時,會向其上添加的target發送事件以執行action。值得註意的是,UIConotrol是UIView的子類,因此本身也具備UIResponder應有的身份。

關於UIControl,此處介紹兩點:

1、target-action執行時機及過程

2、觸摸事件優先級


target-action

  • target:處理交互事件的物件

  • action:處理交互事件的方式


UIControl作為能夠響應事件的控制元件,必然也需要待事件交互符合條件時才去響應,因此也會跟蹤事件發生的過程。不同於UIResponder以及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。


因此,可以通過重寫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.This applies only to gesture recognition that overlaps the default action for a control, which includes:

A single finger single tap on a UIButton, UISwitch, UIStepper, UISegmentedControl, and UIPageControl.

A single finger swipe on the knob of a UISlider, in a direction parallel to the slider.

A single finger pan gesture on the knob of a UISwitch, in a direction parallel to the switch.


簡單理解: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的?即根據什麼判斷應當識別成功還是識別失敗?

糾正


以上所述UIControl的響應優先級比手勢識別器高的說法不准確,準確地說只適用於系統提供的有預設action操作的UIControl,例如UIbutton、UISwitch等的單擊,而對於自定義的UIControl,經驗證,響應優先級比手勢識別器低。讀者可自行驗證,感謝 @閆仕偉 同學的糾正。

撥雲見日


現在,把膠卷回放到本章節開頭的場景。給你一杯咖啡的時間看看能不能解釋得通那幾個現象了,不說了泡咖啡去了…

我肥來了!

先看現象二,短按 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物件維護的手勢識別器陣列:

獲可疑物件: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,籠統地講,事件響應優先級依次遞增。



編號296,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

Web開發

更多推薦18個技術類微信公眾號

涵蓋:程式人生、演算法與資料結構、黑客技術與網絡安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

赞(0)

分享創造快樂