iOS事件分發機制與實踐

還是不夠辣發表於2018-01-29

iOS事件的傳遞與響應是一個重要的話題,網上談論的很多,但大多講述並不完整,本文將結合蘋果官方的文件對事件的傳遞與響應原理及應用實踐做一個比較完整的總結。文章將依次介紹下列內容:

  • 事件的傳遞機制
  • 事件的響應機制
  • 事件傳遞與響應實踐
  • 手勢識別器工作機制
  • 標準控制元件的事件處理

iOS中事件一共有四種型別,包含觸控事件,運動事件,遠端控制事件,按壓事件,本文將只討論最常用的觸控事件。事件通過UIEvent物件描述

UIEvent

UIEvent描述了單次的使用者與應用的互動行為,例如觸控螢幕會產生觸控事件,晃動手機會產生運動事件。UIEvent物件中記錄了事件發生的時間,型別,對於觸控事件,還記錄了一組UITouch物件,下面是UIEvent的幾個屬性:

@property(nonatomic,readonly) UIEventType     type NS_AVAILABLE_IOS(3_0);  //事件的型別
@property(nonatomic,readonly) UIEventSubtype  subtype NS_AVAILABLE_IOS(3_0);
@property(nonatomic,readonly) NSTimeInterval  timestamp;  //事件的時間

@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches;  //事件包含的touch物件
複製程式碼

那麼觸控事件中的UITouch物件描述的是什麼呢?

UITouch

UITouch記錄了手指在螢幕上觸控時產生的一組資訊,包含觸控的時間,位置,所在的視窗或檢視,觸控的狀態,力度等資訊

@property(nonatomic,readonly) NSTimeInterval      timestamp;  //時間
@property(nonatomic,readonly) UITouchPhase        phase;  //狀態,例如begin,move,end,cancel
@property(nonatomic,readonly) NSUInteger          tapCount;   // 短時間內單擊的次數
@property(nonatomic,readonly) UITouchType         type NS_AVAILABLE_IOS(9_0);  //型別
@property(nonatomic,readonly) CGFloat majorRadius NS_AVAILABLE_IOS(8_0);  //觸控半徑
@property(nonatomic,readonly) CGFloat majorRadiusTolerance NS_AVAILABLE_IOS(8_0);
@property(nullable,nonatomic,readonly,strong) UIWindow                        *window;  //觸控所在視窗
@property(nullable,nonatomic,readonly,strong) UIView                          *view;  //觸控所在檢視
@property(nullable,nonatomic,readonly,copy)   NSArray <UIGestureRecognizer *> *gestureRecognizers NS_AVAILABLE_IOS(3_2);  //正在接收該觸控物件的手勢識別器
@property(nonatomic,readonly) CGFloat force NS_AVAILABLE_IOS(9_0);  //觸控的力度
複製程式碼

每一根手指的觸控都會產生一個UITouch物件,多個手指觸控便會有多個UITouch物件,當手指在螢幕上移動時,系統會更新UITouch的部分屬性值,在觸控結束後系統會釋放UITouch物件。

當事件產生後,系統會尋找可以響應該事件的物件來處理事件,如果找不到可以響應的物件,事件就會被丟棄。那麼哪些物件可以響應事件呢?只有繼承於UIResponder的物件才能夠響應事件,UIApplication,UIView,UIViewcontroller均繼承於UIResponder,因此它們均能夠響應事件。UIResponder提供了響應事件的一組方法:

- (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; //觸控被中斷,例如觸控時電話呼入
複製程式碼

如果我們想要對事件進行自定義的處理(比如手指在螢幕滑動時讓某個view跟著移動),我們需要重寫以上四個方法,對於UIViewcontroller,我們只需要在UIViewcontroller中重寫上面四個方法,對於UIView,我們需要建立繼承於UIView的子類,然後在子類中重寫上面的方法,這點需要注意

事件的傳遞

事件產生之後,會被加入到由UIApplication管理的事件佇列裡,接下來開始自UIApplication往下傳遞,首先會傳遞給主window,然後按照view的層級結構一層層往下傳遞,一直找到最合適的view(發生touch的那個view)來處理事件。查詢最合適的view的過程是一個遞迴的過程,其中涉及到兩個重要的方法 hitTest:withEvent:pointInside:withEvent:

當事件傳遞給某個view之後,會呼叫view的hitTest:withEvent:方法,該方法會遞迴查詢view的所有子view,其中是否有最合適的view來處理事件,整個流程如下所示:

hitTest工作流程

hitTest:withEvent:程式碼實現:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    //首先判斷是否可以接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    //然後判斷點是否在當前檢視上
    if ([self pointInside:point withEvent:event] == NO) return nil;
    //迴圈遍歷所有子檢視,查詢是否有最合適的檢視
    for (NSInteger i = self.subviews.count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        //轉換點到子檢視座標系上
        CGPoint childPoint = [self convertPoint:point toView:childView];
        //遞迴查詢是否存在最合適的view
        UIView *fitView = [childView hitTest:childPoint withEvent:event];
        //如果返回非空,說明子檢視中找到了最合適的view,那麼返回它
        if (fitView) {
            return fitView;
        }
    }
    //迴圈結束,仍舊沒有合適的子檢視可以處理事件,那麼就認為自己是最合適的view
    return self;
}
複製程式碼
  • pointInside:withEvent:方法作用是判斷點是否在檢視內,是則返回YES,否則返回NO
  • 判斷一個view是否能夠接收事件有三個條件,分別是,是否禁止使用者互動(userInteractionEnabled = NO),是否被隱藏(hidden = YES)以及透明度是否小於等於0.01(alpha <=0.01)
  • 從遞迴的邏輯我們知道,如果觸控的點不在父view上,那麼其上的所有子view的hitTest都不會被呼叫,需要指出的是,如果子view尺寸超出了父view,並且屬性clipsToBounds設定為NO(也就是子view超出部分不被裁剪),觸控發生在子view超出父view的區域內,依舊不返回子view。反過來,如果觸控的點在父view上並且父view就是最合適的view,那麼它的所有子view的hitTest還是會被呼叫,因為如果不呼叫就無法知道是否還有比父view更合適的子view存在。

事件的響應

在找到最合適的view之後,會呼叫view的touches方法對事件進行響應,如果沒有重寫view的touches方法,touches預設的做法是將事件沿著響應者鏈往上拋,交給下一個響應者物件。也就是說,touches方法預設不處理事件,只是將事件沿著響應者鏈往上傳遞。那麼響應者鏈是什麼呢?

響應者鏈

在應用程式中,檢視放置都是有一定層次關係的,點選螢幕之後該由下方的哪個view來響應需要有一個判斷的方式。響應者鏈是由一系列可以響應事件的物件(繼承於UIResponder)組成的,它決定了響應者物件響應事件的先後順序關係。下圖展示了UIApplication,UIViewcontroller以及UIView之間的響應關係鏈:

響應者鏈

響應者鏈在遞迴查詢最合適的view的時候形成,所找到的view將成為第一響應者,會呼叫它的touches方法來響應事件,touches方法預設的處理是將事件往上拋給下一個響應者,而如果下一個響應者的touches方法沒有重寫,事件會繼續沿著響應者鏈往上走,一直到UIApplication,如果依舊不能處理事件那麼事件就被丟棄。

  • UIView

    如果view是viewcontroller的根view,那麼下一個響應者是viewcontroller,否則是super view

  • UIViewcontroller

    如果viewcontroller的view是window的根view,那麼下一個響應者是window;如果viewcontroller是另一個viewcontroller模態推出的,那麼下一個響應者是另一個viewcontroller;如果viewcontroller的view被add到另一個viewcontroller的根view上,那麼下一個響應者是另一個viewcontroller的根view

  • UIWindow

    UIWindow的下一個響應者是UIApplication

  • UIApplication

    通常UIApplication是響應者鏈的頂端(如果app delegate也繼承了UIResponder,事件還會繼續傳給app delegate)

事件傳遞與響應實踐

首先我們通過程式碼建立一個具有層次結構的檢視集合,在viewcontroller的viewDidLoad中新增如下程式碼:

    greenView *green = [[greenView alloc] initWithFrame:CGRectMake(50, 50, 300, 500)];
    [self.view addSubview:green];
    
    redView *red = [[redView alloc] initWithFrame:CGRectMake(0, 0, 200, 300)];
    [green addSubview:red];
    
    orangeView *orange = [[orangeView alloc] initWithFrame:CGRectMake(0, 350, 200, 100)];
    [green addSubview:orange];
    
    blueView *blue = [[blueView alloc] initWithFrame:CGRectMake(10, 10, 100, 100)];
    [red addSubview:blue];
複製程式碼

執行後如下所示:

檢視

要實現我們自定義的事件處理邏輯,通常有兩種方式,我們可以重寫hitTest:withEvent:方法指定最合適處理事件的檢視,即響應鏈的第一響應者,也可以通過重寫touches方法來決定該由響應鏈上的誰來響應事件。

  • 情景1:點選黃色檢視,紅色檢視響應

黃色檢視和紅色檢視均為綠色檢視的子檢視,我們可以重寫綠色檢視的hitTest:withEvent:方法,在其中直接返回紅色檢視,程式碼示例如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    if ([self pointInside:point withEvent:event] == NO) return nil;
    //紅色檢視是先被add的,所以是第一個元素
    return self.subviews[0];
}
複製程式碼

我們這裡是重寫了父檢視的hitTest方法,而不是重寫紅色檢視的hitTest方法並讓它返回自身,道理也很顯然,在遍歷綠色檢視所有子檢視的過程中,可能還沒來得及呼叫到紅色檢視的hitTest方法時,就已經遍歷到了觸控點真正所在的黃色檢視,這個時候重寫紅色檢視的hitTest方法是無效的。

  • 情景2:點選紅色檢視,綠色檢視響應(也就是事件透傳)

我們可以重寫紅色檢視的hitTest方法,讓其返回空,這時候便沒有了合適的子檢視來響應事件,父檢視即綠色檢視就成為了最合適的響應事件的檢視,程式碼示例如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    return nil;
}
複製程式碼

當然,我們也可以重寫綠色檢視的hitTest方法,讓其直接返回自身,也能實現同樣效果,不過這樣的話點選其它子檢視(比如黃色檢視)就也不能響應事件了,因此如何處理需要視情況而定。

  • 情景3:點選紅色檢視,紅色和綠色檢視均做響應

我們知道,事件在不能被處理時,會沿著響應者鏈傳遞給下一個響應者,因此我們可以重寫響應者物件的touches方法來實現讓一個事件多個響應者物件響應的目的。因此我們可以通過重寫紅色檢視的touches方法,先做自己的處理,然後在把事件傳遞給下一個響應者,程式碼示例如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"red touches begin");  //自己的處理
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"red touches moved");  //自己的處理
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"red touches end");  //自己的處理
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"red touches canceled");  //自己的處理
    [super touchesBegan:touches withEvent:event];
}
複製程式碼

需要說明的是,事件傳遞給下一個響應者時,用的是super而不是superview,這並沒有問題,因為super呼叫了父類的實現,而父類預設的實現就是呼叫下一個響應者的touches方法。如果直接呼叫superview反而會有問題,因為下一個響應者可能是viewcontroller

手勢識別器

事實上,我們要處理事件除了使用前面提到的方式,還有另一種方式,就是手勢識別器。手勢識別器可以很方便的處理常用的各種觸控事件,常見的手勢包括單擊、拖動,長按,橫掃或豎掃,縮放,旋轉等,另外我們還可以建立自定義的手勢。

UIGestureRecognize是手勢識別器的父類,所有具體的手勢識別器均繼承於該父類,如果我們自定義手勢,也需要繼承該類。然而,該類並沒有繼承於UIResponder,所以手勢識別器並不參與響應者鏈。那麼手勢識別器是如何工作的呢?

手勢識別器工作機制

當觸控螢幕產生touch事件後,UIApplication會將事件往下分發,如果檢視繫結了手勢識別器,那麼touch事件會優先傳遞給繫結在檢視上的手勢識別器,然後手勢識別器會對手勢進行識別,如果識別出了手勢,就會呼叫建立手勢時所繫結的回撥方法,並且會取消將touch事件繼續傳遞給其所繫結的檢視,如果手勢識別器沒有識別出對應的手勢,那麼touch事件會繼續向手勢識別器所繫結的檢視傳遞。

雖然手勢識別器並不是響應者鏈中的一員,但是手勢識別器像一個觀察者,會在一旁觀察touch事件,並延遲事件向所繫結的檢視傳遞,這短暫的延遲使手勢識別器有機會優先去識別手勢處理touch事件。

標準控制元件的事件處理

對於UIKit提供的的標準控制元件,可以很方便地通過Target-Action的方式增加事件處理邏輯(例如UIButton的addTarget方法),那麼Target-Action,手勢識別器,以及touches方法的優先順序是怎樣的呢?

  • 情景1

    我們以UIbutton為例,首先繼承UIbutton並重寫touches方法,然後建立button物件並繫結單擊手勢,然後再通過addtarget的方式新增點選事件。三者同時存在時,手勢識別器優先響應,其他方式不再響應,手勢識別器不存在時,touches方法優先響應,僅當UIbutton沒有繫結手勢識別器,也沒有被重寫touches方法時,target-action方式才會響應。這裡我們也可以推測target-action方式應該就是重寫了button的touches方法

  • 情景2

    仍以UIbutton為例,我們建立button物件,並在button的父檢視上繫結手勢(或者重寫父檢視的touches方法),結果是button的target-action方式優先進行了響應,父檢視並沒有響應。這也很顯然,從hittest的遞迴邏輯看,當發現了合適的子檢視(button)時就直接由子檢視第一響應,父檢視將不是最合適的響應者,當然它處於響應者鏈的上一層。

相關文章