事件傳遞和響應鏈

Code.Rookie發表於2019-06-10

前言

看關於這方面的文章基本沒有能涉及到UIGestureRecognizers相關的文章,因此決定寫這樣一篇文章。也是我的第一篇文章,如有什麼不對請及時指正。
本文主要通過一些實際測試來便於大家理解。

正文

事件傳遞和響應鏈流程圖

  • IOKit.framework 為系統核心的庫
  • SpringBoard.app 相當於手機的桌面
  • Source1 主要接收系統的訊息
  • Source0 - UIApplication - UIWindow
  • 從window開始系統會呼叫hitTest:withEvent:pointInside來找到最優響應者,具體過程可參考下圖
    hitTest
    • 比如我們在self.view 上依次新增view1、view2、view3(3個view是同級關係),那麼系統用hitTest以及pointInside時會先從view3開始便利,如果pointInside返回YES就繼續遍歷view3的subviews(如果view3沒有子檢視,那麼會返回view3),如果pointInside返回NO就開始遍歷view2。反序遍歷,最後一個新增的subview開始。也算是一種演算法優化。後面會具體介紹hitTest的內部實現和具體使用場景。
  • UITouch會給gestureRecognizers和最優響應者也就是hitTestView傳送訊息
    • 預設view會走其touchBegan:withEvent:等方法,當gestureRecognizers找到識別的gestureRecognizer後,將會獨自佔有該touch,即會呼叫其他gestureRecognizer和hitTest view的touchCancelled:withEvent:方法,並且它們不再收到該touche事件,也就不會走響應鏈流程。下面會具體闡述UIContol和UIScrollView和其子類與手勢之間的衝突和關係。
  • 當該事件響應完畢,主執行緒的Runloop開始睡眠,等待下一個事件。

1.hitTest:withEvent:和pointInside

1.1 hitTest:withEvent:和pointInside 演練

  • 測試hitTest和pointInside執行過程

    GSGrayView *grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    [self.view addSubview:grayView];
    
    GSRedView *redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, grayView.bounds.size.width / 2, grayView.bounds.size.height / 3)];
    [grayView addSubview:redView];
    
    GSBlueView *blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(grayView.bounds.size.width/2, grayView.bounds.size.height * 2/3, grayView.bounds.size.width/2, grayView.bounds.size.height/3)];
    
    // blueView.userInteractionEnabled = NO;
    // blueView.hidden = YES;
    // blueView.alpha = 0.1;//0.0;
    [grayView addSubview:blueView];
    
    GSYellowView *yellowView = [[GSYellowView alloc] initWithFrame:CGRectMake(CGRectGetMinX(grayView.frame), CGRectGetMaxY(grayView.frame) + 20, grayView.bounds.size.width, 100)];
    [self.view addSubview:yellowView];

    hitTest測試

    點選redView:
    yellowView -> grayView -> blueView -> redView
    事件傳遞和響應鏈
  • 當點選redView時,因為yellowView和grayView同級,yellowView比grayView後新增,所以先列印yellowView,由於觸控點不在yellowView中因此列印grayView,然後遍歷grayView的subViews分別列印blueView和redView。
  • 當hitTest返回nil時,也不會列印pointInside。因此可以得出pointInside是在hitTest後面執行的。
  • 當view的userInteractionEnabled為NO、hidden為YES或alpha<=0.1時,也不會列印pointInside方法。因此可以推斷出在hitTest方法內部會判斷如果這些條件一個成立則會返回nil,也不會呼叫pointInside方法。
  • 如果在grayView的hitTest返回[super hitTest:point event:event],則會執行gery.subviews的遍歷(subviews 的 hitTest 與 pointInside),grayView的pointInside是判斷觸控點是否在grayView的bounds內,grayView的hitTest是判斷是否需要遍歷他的subviews.
  • pointInside只是在執行hitTest時,會在hitTest內部呼叫的一個方法。也就是說pointInside是hitTest的輔助方法。
  • hitTest是一個遞迴函式

    1.2 hitTest:withEvent:內部實現程式碼還原

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"-----%@",self.nextResponder.class);
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) return nil;
    //判斷點在不在這個檢視裡
    if ([self pointInside:point withEvent:event]) {
        //在這個檢視 遍歷該檢視的子檢視
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            //轉換座標到子檢視
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            //遞迴呼叫hitTest:withEvent繼續判斷
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                //在這裡列印self.class可以看到遞迴返回的順序。
                return hitTestView;
            }
        }
        //這裡就是該檢視沒有子檢視了 點在該檢視中,所以直接返回本身,上面的hitTestView就是這個。
        NSLog(@"命中的view:%@",self.class);
        return self;
    }
    //不在這個檢視直接返回nil
    return nil;
}

1.3 pointInside運用:增大熱區範圍

  • 在開發過程中難免會遇到需要增大UIButton等的熱區範圍,假如UIButton的佈局不允許修改,那麼就需要用到pointInside來增大UIButton的點選熱區範圍。具體實現程式碼如下:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    
    NSLog(@"%@ -- pointInside",self.class);
    CGRect bounds = self.bounds;
    //若原熱區小於200x200,則放大熱區,否則保持原大小不變
    //一般熱區範圍為40x40 ,此處200是為了便於檢測
    CGFloat widthDelta = MAX(200 - bounds.size.width, 0);
    CGFloat heightDelta = MAX(200 - bounds.size.height, 0);
    bounds = CGRectInset(bounds, -0.5 * widthDelta, -0.5 * heightDelta);
    return CGRectContainsPoint(bounds, point);
    
}
  • 也就是說如果button的size小於200*200,則點選button相對中心位置上下左右各100的範圍內即使超出button,也可以響應點選事件

2.響應鏈

2.1 響應鏈的組成

respondChain

還用上面那個栗子:
點選redView:
redview -> grayView -> viewController -> ...
事件傳遞和響應鏈
因為只實現到controller的touches事件方法因此只列印到Controller。

  • 響應鏈是通過nextResponder屬性組成的一個連結串列。
    • 點選的view有 superView,nextResponder就是superView;
    • view.nextResponder.nextResponder是viewController 或者是 view.superView. view
    • view.nextResponder.nextResponder.nextResponder是 UIWindow (非嚴謹,便於理解)
    • view.nextResponder.nextResponder.nextResponder. nextResponder是UIApplication、UIAppdelate、直到nil (非嚴謹,便於理解)
  • touch事件就是根據響應鏈的關係來層層呼叫(我們重寫touch 要記得 super 呼叫,不然響應鏈會中斷)。
  • 比如我們監聽self.view的touch事件,也是因為subviews的touch都在同一個響應鏈裡。

2.2 UIControl阻斷響應鏈

把上面栗子中的grayView替換成一個Button:

    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    expandButton.backgroundColor = [UIColor lightGrayColor];
    [expandButton setTitle:@"點我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchDown];
    [self.view addSubview:expandButton];
    
    self.redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, expandButton.bounds.size.width / 2, expandButton.bounds.size.height / 3)];
    [expandButton addSubview:self.redView];
    
    self.blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(expandButton.bounds.size.width/2, expandButton.bounds.size.height * 2/3, expandButton.bounds.size.width/2, expandButton.bounds.size.height/3)];
    
    //    blueView.userInteractionEnabled = NO;
    //    blueView.hidden = YES;
    //    blueView.alpha = 0.1;//0.0;
    [expandButton addSubview:self.blueView];
    
    self.yellowView = [[GSYellowView alloc] initWithFrame:CGRectMake(CGRectGetMinX(expandButton.frame), CGRectGetMaxY(expandButton.frame) + 20, expandButton.bounds.size.width, 100)];
    [self.view addSubview:self.yellowView];

點選redView:
redview -> expandButton
事件傳遞和響應鏈

  • 雖然點選redView,雖然button的touches事件方法也走了但是依然不會響應button的target的action方法,只是會傳遞到button而已,因為最佳響應著依然是redView。
  • 從上面測試結果可以看出,UIControl會阻斷響應鏈的傳遞,也就是說在響應UIContol的touches事件時並不會呼叫nextResponder的對應的方法。
  • 通過在Button子類中重寫touches的方法,發現如果不呼叫super的touches對應的方法則不會響應點選事件。由此可以大致推斷出UIControl其子類響應點選原理大致為:根據新增target:action:時設定的UIControlEvents,在touches的合適方法呼叫target的action方法。

2.3UIScrollView阻斷響應鏈

self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth, kScreenHeight * 3 / 4)];
    [self.view addSubview:self.grayView];
    
    self.tableView = [[GSTableView alloc] initWithFrame:CGRectMake(0, 20, kScreenWidth, self.grayView.bounds.size.height / 2)];
    self.tableView.dataSource = self;
    self.tableView.backgroundColor = [UIColor darkGrayColor];
    self.tableView.delegate = self;
    [self.grayView addSubview:self.tableView];
    
    self.redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth/2, self.tableView.bounds.size.height/2)];
    [self.tableView addSubview:self.redView];

事件傳遞和響應鏈

點選redview
redview -> tableView
事件傳遞和響應鏈

  • 從上面測試結果可以得出,UIScrollView也會阻斷響應鏈,也就是說在響應UIScrollView自身對touch的處理方式並不會呼叫nextResponder對應的方法。
  • 通過重寫tableView子類的touches方法,發現如果不呼叫super的touches對應的方法則不會走tableview:didSelectRowAtIndexPath:方法。由此可以大致推斷出UIScrollView其子類是在其touches方法中處理點選事件的。

3.手勢

3.1手勢的探索以及和touch事件的關係

在上面栗子中的view增加gestureRecognizer:

- (void)addGesture {
    GSGrayGestureRecognizer *grayGesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:grayGesture];
    
    GSRedGestureRecognizer *redGesture = [[GSRedGestureRecognizer alloc] initWithTarget:self action:@selector(redViewClick:)];
    [self.redView addGestureRecognizer:redGesture];
    
    GSBlueGestureRecognizer *blueGesture = [[GSBlueGestureRecognizer alloc] initWithTarget:self action:@selector(blueViewClick:)];
    [self.blueView addGestureRecognizer:blueGesture];
}

點選redView
列印結果如下圖所示:
事件傳遞和響應鏈

  • 當通過hitTest和pointInside找到最優響應者後,會給gestureRecognizers和相應的view同時傳送touchBegin訊息,如果找到合適gestureRecognizer則會獨有該touches,即呼叫view的touheCancel訊息,接著有gestreRecognizer來響應事件。
  • 上面為預設情況下手勢和touches之間的關係,其實我們可以通過gestureRecognizer的屬性來控制它們之間的一些關係。
// 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; 

// default is NO.  causes all touch or press events to be delivered to the target view only after this gesture has failed recognition. set to YES to prevent views from processing any touches or presses that may be recognized as part of this gesture      
@property(nonatomic) BOOL delaysTouchesBegan;         

 // default is YES. causes touchesEnded or pressesEnded events to be delivered to the target view only after this gesture has failed recognition. this ensures that a touch or press that is part of the gesture can be cancelled if the gesture is recognized
@property(nonatomic) BOOL delaysTouchesEnded;        
  • cancelsTouchesInView:預設為YES。表示當手勢識別成功後,取消最佳響應者物件對於事件的響應,並不再向最佳響應者傳送事件。若設定為No,則表示在手勢識別器識別成功後仍然向最佳響應者傳送事件,最佳響應者仍響應事件。
  • delaysTouchesBegan:預設為NO,即在手勢識別器識別手勢期間,觸控物件狀態發生變化時,都會傳送給最佳響應者,若設定成yes,則在識別手勢期間,觸控狀態發生變化時不會傳送給最佳響應者。
  • delaysTouchesEnded:預設為NO。預設情況下當手勢識別器未能識別手勢時,若此時觸控已經結束,則會立即通知Application傳送狀態為end的touch事件給最佳響應者以呼叫 touchesEnded:withEvent: 結束事件響應;若設定為YES,則會在手勢識別失敗時,延遲一小段時間(0.15s)再呼叫響應者的 touchesEnded:withEvent:。

3.2手勢和UIControl的關係

  • 上面已經說了UIContol會阻斷響應鏈。那麼我們再來進一步探索UIControl的阻斷和手勢之間的關係。
// button在上面
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    
    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(30, kTopHeight, 100, 100)];
    expandButton.backgroundColor = [UIColor redColor];
    [expandButton setTitle:@"點我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.grayView addSubview:expandButton];

事件傳遞和響應鏈

點選button
事件傳遞和響應鏈

  • 從該栗子中可以看出即使下層view新增收拾依然會響應按鈕的點選事件。

  • 由此可以猜測原因:
    1. UIControl及其子類會阻斷響應鏈。(後面驗證是錯誤的)
    2. UIControl及其子類為最優響應者時會優先處理它們的事件。(後面驗證成功)
    • 驗證猜測一:
    • 有手勢的view上增加一個阻斷響應鏈的view
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    GSCancelledTouchView *cancelTouchView = [[GSCancelledTouchView alloc] initWithFrame:CGRectMake(30, kTopHeight, 100, 100)];
    [self.grayView addSubview:cancelTouchView];
    事件傳遞和響應鏈
    點選greenView
    事件傳遞和響應鏈
    • greenView是一個阻斷響應鏈的view(即重新超類touches方法沒用呼叫超類方法),但是依然響應gestureRecognizer的target:action:方法,並且呼叫touches事件的toucesCancelled的方法。因此猜測1是錯誤的。
    • 驗證猜測二:
    • 有收拾的view上增加一個button,button上增加一個view
        // 驗證不取消button的touches事件猜測二
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    
    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth/3, 200)];
    expandButton.backgroundColor = [UIColor redColor];
    [expandButton setTitle:@"點我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.grayView addSubview:expandButton];
    
    
    self.blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    [expandButton addSubview:self.blueView];
    事件傳遞和響應鏈
    點選blueView
    事件傳遞和響應鏈
    • 點選blueview雖然expandButton會阻斷響應鏈但是依然會執行在grayview上的手勢方法並且會呼叫touchesCancelled方法,因此可以驗證猜想二是正確的。
    • 把grayview上的gestureRecognizer去掉,依然不會響應expandButton上的點選事件,因為最優響應者不是expandButton。
  • UIControl及其子類能夠執行點選事件而不是走底層的手勢的原因為:在識別到相應的gestureRecognizer後如果當前的最優響應者是UIControl及其子類並且當前的gestureRecognizer不是UIContol上的手勢,則會響應UIControl的target:action:的方法。否則則會響應gestureRecognizer的target:action:的方法。

3.3 手勢和UIScrollView的關係

  • UITableView是UIScroll子類的常用類,因此拿UITableView來舉栗子。
self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth, kScreenHeight * 3 / 4)];
    GSGrayGestureRecognizer *grayGesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    //    grayGesture.delaysTouchesBegan = YES;
    //    grayGesture.cancelsTouchesInView = NO;
    //    grayGesture.delaysTouchesEnded = YES;
    [self.grayView addGestureRecognizer:grayGesture];
    [self.view addSubview:self.grayView];
    
    self.tableView = [[GSTableView alloc] initWithFrame:CGRectMake(0, 20, kScreenWidth, self.grayView.bounds.size.height / 2)];
    self.tableView.dataSource = self;
    self.tableView.backgroundColor = [UIColor darkGrayColor];
    self.tableView.delegate = self;
    [self.grayView addSubview:self.tableView];

事件傳遞和響應鏈
點選tableView
當父控制元件沒有手勢時
事件傳遞和響應鏈
當父控制元件有手勢時
事件傳遞和響應鏈

  • 由上面的例子可以得出當UIScrollView為最優響應者並且父控制元件沒有手勢時UIScrollView才可以自己處理點選事件。否則被父控制元件的gestureRecognizer佔有。
  • 從上面結果看出當父控制元件有手勢時UIScrollView的touches方法都不執行,類似於設定delaysTouchesBegan為YES。
  • 雖然UIScrollView及其子類和UIControl及其子類類似都可以阻斷響應鏈,但是當UIScrollView及其子類為最優響應者時,如果父控制元件中有gestureRecognizer依然會被其佔有。
UIScrollView點選穿透解決方案

當UIScrollView為最優響應者父控制元件有手勢時,UIScrollView及其子類的點選代理方法和touchesBegan方法不響應。

解決方法:三種解決方式,個人認為第二種為最優解決方案

  • 可以通過給父控制元件手勢設定cancelsTouchesInView為NO,則會同時響應gestureRecognizer的事件和UIScrollView及其子類的代理方法和touches事件。

  • 給父控制元件中的手勢的代理方法裡面做一下判斷,當touch的view是我們需要觸發的view的時候,return NO ,這樣就不會走手勢方法,而去觸發這個touch.view這個物件的方法了。

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    if ([NSStringFromClass([touch.view class])    isEqualToString:@"UITableViewCellContentView"]) {
        return NO;
    }
    return YES;
}
  • 可以通過給UIScrollView及其子類新增gestureRecognizer,從而來呼叫需要處理的事情。

文章若有不對地方,歡迎批評指正

相關文章