iOS事件分發機制與實踐

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

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

iOS中事件一共有四種類型,包含觸摸事件,運動事件,遠程控制事件,按壓事件,本文將只討論最經常使用的觸摸事件。事件經過UIEvent對象描述app

UIEvent

UIEvent描述了單次的用戶與應用的交互行爲,例如觸摸屏幕會產生觸摸事件,晃動手機會產生運動事件。UIEvent對象中記錄了事件發生的時間,類型,對於觸摸事件,還記錄了一組UITouch對象,下面是UIEvent的幾個屬性:ide

@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對象描述的是什麼呢?ui

UITouch

UITouch記錄了手指在屏幕上觸摸時產生的一組信息,包含觸摸的時間,位置,所在的窗口或視圖,觸摸的狀態,力度等信息atom

@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對象。spa

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

- (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的子類,而後在子類中重寫上面的方法,這點須要注意cdn

事件的傳遞

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

當事件傳遞給某個view以後,會調用view的hitTest:withEvent:方法,該方法會遞歸查找view的全部子view,其中是否有最合適的view來處理事件,整個流程以下所示:blog

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)時就直接由子視圖第一響應,父視圖將不是最合適的響應者,固然它處於響應者鏈的上一層。

相關文章
相關標籤/搜索