iOS事件傳遞以及響應綜合分析

  1. 響應者對象UIResponder
  2. 事件傳遞
    • 事件傳遞過程
    • 關于hitTest:withEvent:方法解析
  3. 事件響應者鏈條
    • 應用舉例:
  4. 手勢的共存和互斥
    • 綜合案例
  5. 手勢和View的點擊事件關系

一. 響應者對象UIResponder

在用戶使用APP的過程中,會產生各種各樣的事件 ,iOS中的事件可以分為3大類型 :


在iOS中不是任何對象都能處理事件的,只有繼承了UIResponder的對象才能接收并處理事件,我們稱之為響應者對象

那么為什么繼承自UIResponder的類就能夠接收并處理事件呢?因為該類中提供了以下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;

二. 事件傳遞

下面我們通過一張圖來看看iOS中事件的產生和傳遞過程:


  1. 當發生觸摸事件后,系統會將該事件加入到一個由UIApplication管理的隊列事件中
  2. 然后UIApplication對象會從事件隊列中取出最前面的事件,并將事件分發下去以便處理,通常會先將該事件發送給應用程序的主窗口(keyWindow)
  3. 主窗口會在視圖層次結構中找到一個最合適的視圖來處理該觸摸事件
  4. 找到合適的視圖控件后,就會調用視圖控件的touches方法來作事件的具體處理:touchesBegin... touchesMoved...touchesEnded等
  5. 這些touches方法默認的做法是將事件順著響應者鏈條向上傳遞,將事件交給上一個相應者進行處理

下面我們舉個例子來演示下具體的傳遞過程,如圖:


一般事件的傳遞是從父控件傳遞到子控件的,如果父控件接受不到觸摸事件,那么子控件就不可能接收到觸摸事件

例如:點擊了綠色的View,傳遞過程如下:UIApplication->Window->白色View->綠色View
點擊藍色的View,傳遞過程如下:UIApplication->Window->白色View->橙色View->藍色View

關于hitTest:withEvent:方法
  1. iOS系統檢測到手指觸摸操作時會將其放入當前活動Application的事件隊列,UIApplication會從事件隊列中取出觸摸事件并傳遞給key window處理,window對象首先會調用 hitTest:withEvent:方法, 而該方法內部會調用pointInside:withEvent:方法,該方法內部通過倒敘便利的方式也就是最先便利最后加入的子視圖,從而來判斷觸摸點是否在該View區域內,如果pointInside返回YES,則表明觸摸事件發生在該View內部,此時系統會遍歷該View的所有Subview 尋找最小單位的UIView

  2. 如果當前View.userInteractionEnabled = NOenabled=NO(UIControl) 或者alpha<=0.01hidden等情況的時候,hitTest就不會調用自己的pointInside,直接返回nil,然后系統就會去遍歷兄弟節點。
    注意:UIImageViewuserInteractionEnabled默認就是NO,因此UIImageView以及它的子控件默認是不能接收到觸摸事件的。

  3. 如果一個子視圖的區域超過父視圖的區域,比如下圖,tabBar 中間的item


正常情況下對超出tabBar區域的觸摸操作不會被識別,因為tabBar的pointInside:withEvent:方法會返回NO,這樣就不會繼續向下遍歷子視圖了。當然,我們可以重寫pointInside:withEvent:方法來處理這種情況,下文會詳細描述。

判斷下當前這個點在不在方法調用者上,注意:這個點必須是方法調用者上的坐標系,才會判斷準確。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds

下面我們用代碼來模擬下這個過程:

// 作用:尋找最合適view
// point:表示方法調用者坐標系上的點
// 什么時候調用:只要一個事件傳遞給一個控件,就會調用這個控件的hitTest方法,該方法返回誰,誰就是最合適view
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 1.判斷下自己能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    
    // 2.判斷下點在不在當前控件上
    if ([self pointInside:point withEvent:event] == NO) return  nil; // 點不在當前控件
    
    
    // 3.從后往前遍歷自己的子控件
    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;
        }
    }
    
    // 4.如果沒有比自己合適的子控件,最合適的view就是自己
    return self;
}

三. 事件響應者鏈條

所謂的事件響應者鏈條就是由多個響應者對象連接起來的鏈條,大致如下:

事件的完整處理過程

  1. 當用戶點擊屏幕后產生觸摸事件,系統先將事件對象由上往下傳遞,也就是由父控件傳遞給子控件,直到找到最合適的控件來處理這個事件。
  2. 找到最合適的視圖控件后,調用該控件的 touches... 系列方法來作具體的事件處理
    • 如果該視圖控件中調用了 [super touches...],則將事件順著響應者鏈條往上傳遞,傳遞給上一個響應者對象,依次類推
    • 如果該控件沒有實現touches... 系列方法,則將事件順著響應者鏈條往上傳遞,傳遞給上一個響應者對象,依次類推

注意:
事件的傳遞是從上到下,由父控件到子控件,而事件的響應是從下到上,是順著響應者鏈條向上傳遞,由子控件到父控件的。他們是相反的。

應用舉例:

1、擴大UIButton的響應熱區
有時候因為控件太小,我們想擴大他的點擊響應區域,此時我們可以:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect frame = [self getScaleFrame];
    return CGRectContainsPoint(frame, point);
}

- (CGRect)getScaleFrame {
    
    CGRect rect = self.bounds;
    if (rect.size.width < 40.f) {
        rect.origin.x -= (40-rect.size.width)/2;
    }
    
    if (rect.size.height < 40.f) {
        rect.origin.y -= (40-rect.size.height)/2;
    }
    rect.size.width = 40.f;
    rect.size.height = 40.f;
    return rect;
}

2、子view超出了父view的bounds響應事件
項目中常常遇到button已經超出了父view的范圍但仍需可點擊的情況,比如自定義Tabbar中間的大按鈕,點擊超出Tabbar bounds的區域也需要響應

//重寫UITabBar的pointInside方法
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    // 1. 轉換點擊在tabbar上的坐標點, 到中間按鈕上
    CGPoint pointInMiddleBtn = [self convertPoint:point toView:self.middleView];

    // 2. 確定中間按鈕的圓心
    CGPoint middleBtnCenter = CGPointMake(33, 33);

    // 3. 計算點擊的位置距離圓心的距離
    CGFloat distance = sqrt(pow(pointInMiddleBtn.x - middleBtnCenter.x, 2) + pow(pointInMiddleBtn.y - middleBtnCenter.y, 2));

    // 4. 判定中間按鈕區域之外
    if (distance > 33 && pointInMiddleBtn.y < 18) {
        return NO;
    }

    return YES;
}

3、方形按鈕的內切圓點擊
如下圖 是一個正方形的UIButton,但是此時我們只想讓它的內切圓接收點擊事件,而4個角落是不接受點擊事件的

@implementation CustomButton

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (!self.userInteractionEnabled ||[self isHidden] ||self.alpha <= 0.01) {
        return nil;
    }
    
    if ([self pointInside:point withEvent:event]) {
        //遍歷當前對象的子視圖
        __block UIView *hit = nil;
        [self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            // 坐標轉換
            CGPoint vonvertPoint = [self convertPoint:point toView:obj];
            //調用子視圖的hittest方法
            hit = [obj hitTest:vonvertPoint withEvent:event];
            // 如果找到了接受事件的對象,則停止遍歷
            if (hit) {
                *stop = YES;
            }
        }];
        
        if (hit) {
            return hit;
        }
        else{
            return self;
        }
    }
    else{
        return nil;
    }
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    CGFloat x1 = point.x;
    CGFloat y1 = point.y;
    
    CGFloat x2 = self.frame.size.width / 2;
    CGFloat y2 = self.frame.size.height / 2;
    
    //圓的標準方程(x-a)2+(y-b)2=r2中, ab為圓心,r為半徑
    double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
    // 67.923
    if (dis <= self.frame.size.width / 2) {
        return YES;
    }
    else{
        return NO;
    }
}

@end

手勢代理方法

 //  是否允許同時支持多個手勢,默認只支持一個手勢,要調用此方法注意設置代理
  - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
  {
      return YES;
  }
  
  //  是否允許開始觸發手勢
  - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
  {
      return NO;
  }
  
  //  是否允許接收手機的觸摸(可以控制觸摸的范圍)
  - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
  {
      //獲取當前的觸摸點
      CGPoint currentP = [touch locationInView:self.imageView];
      //在圖片的左半區域可以接受觸摸
      if (currentP.x < self.imageView.bounds.size.width * 0.5) {
          return YES;
      }else {
          return NO;
      }
  }

四. 手勢的共存和互斥

首先我們來看看下面這段代碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    GSViewOne *viewOne = [[GSViewOne alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    viewOne.backgroundColor = [UIColor redColor];
    [self.view addSubview:viewOne];
    
    GSViewTwo *viewTwo = [[GSViewTwo alloc] initWithFrame:CGRectMake(20, 20, 160, 160)];
    viewTwo.backgroundColor = [UIColor yellowColor];
    [viewOne addSubview:viewTwo];
    
    //添加手勢
    GSGestureOne *gestureOne = [[GSGestureOne alloc] initWithTarget:self action:@selector(panOne)];
    [viewOne addGestureRecognizer:gestureOne];
    
    GSGestureTwo *gestureTwo = [[GSGestureTwo alloc] initWithTarget:self action:@selector(panTwo)];
    [viewTwo addGestureRecognizer:gestureTwo];
}

-(void)panOne{
    NSLog(@"panOne--redView");
}
-(void)panTwo{
    NSLog(@"panTwo--yellowView");
}

效果圖如下:


手勢共存
當我們的手指在黃色View上拖拽的時候發現只識別了黃色區域的手勢,那么現在有一個需求,當手指在黃色區域拖拽的時候我要黃色和紅色區域的手勢都識別該如何實現?

此時我們只需要實現UIGestureRecognizerDelegate協議,實現如下方法即可:

    //允許手勢共存,只要有一個手勢返回了YES,那么就是共存
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{
    return YES;
}

手勢互斥
當我們手指在黃色區域拖拽的時候我希望紅色區域手勢識別而黃色區域手勢不識別 ,此時就用到了手勢互斥。

    //gestureTwo的響應需要gestureOne響應失敗
    [gestureTwo requireGestureRecognizerToFail:gestureOne];

或者是用代理方法也可以:

///otherGestureRecognizer它要識別,需要gestureRecognizer被響應失敗
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {

    return YES;

}

五. 手勢和View的點擊事件關系

現在有一個案例,BaseVC中添加了一個手勢

@implementation BaseVC
- (void)viewDidLoad {
    [super viewDidLoad];
    
    UITapGestureRecognizer *tapGes = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapClick)];
    [self.view addGestureRecognizer:tapGes];
}
-(void)tapClick{
    NSLog(@"%s",__func__);
}
@end

ViewController繼承自BaseVC,在ViewController中添加了一個tableView,并且實現了didSelectRowAtIndexPath:方法

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    NSLog(@"%s",__func__);
}

運行起來之后,點擊Cell,發現只執行了基類的點擊手勢,而沒有執行Cell的點擊事件,這是因為什么原因呢?


這是由于手勢是大哥,點擊事件是小弟,可以理解為手勢優于點擊事件。其實是因為手勢有一個cancelsTouchesInView屬性,該屬性默認值為YES,表示識別手勢之后,是否取消view的touch事件,我們只需設置該屬性為NO即可。

// default is YES. causes touchesCancelled:withEvent: or pressesCancelled:withEvent: to be sent to the  
//view for all touches or presses recognized as part of this gesture immediately before the action method is called.
@property(nonatomic) BOOL cancelsTouchesInView;       
tapGes.cancelsTouchesInView = NO;//識別手勢之后,是否取消view的touch事件,默認值為YES

但是當點擊Cell的時候我們只想執行Cell的點擊事件而不想執行父類的手勢事件,該如何操作呢?

我們只需要實現手勢的代理方法即可:

// called before touchesBegan:withEvent: is called on the gesture recognizer for a new touch. 
// return NO to prevent the gesture recognizer from seeing this touch
-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch{
    if ([touch.view isKindOfClass:[UITableView class]]) {
           return YES;
       }
       return  NO;
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容