iOS事件傳遞與響應者鏈

用戶以多種方式操縱他們的iOS設備,例如觸摸屏幕或搖動設備。 iOS會解釋用戶何時以及如何操作硬件并將此信息傳遞到您的應用程序。 您的應用程序以自然和直觀的方式響應操作的次數越多,對用戶而言越有吸引力的體驗。

一、事件分類

事件是發(fā)送到應用程序用于通知用戶操作的對象。 在iOS中,事件可以采取多種形式:多點觸摸事件,運動事件和用于控制多媒體的事件。 這最后一種類型的事件被稱為遙控事件或者遠程控制事件,因為它可以源自外部附件。而在我們開發(fā)過程中最常用的就是多點觸摸事件。

Event in iOS

二、事件傳遞與響應鏈

當您設計應用程式時,可能需要動態(tài)響應事件。 例如,觸摸可以發(fā)生在屏幕上的許多不同對象中,并且您必須決定您想要那個對象響應事件,并且理解該對象如何接收該事件。

當用戶生成的事件發(fā)生時,UIKit創(chuàng)建一個包含處理事件所需信息的事件對象。 然后它將事件對象放置在活動應用程序的事件隊列中。 對于觸摸事件,該對象是在UIEvent對象中打包的一組觸摸。 對于運動事件,事件對象因您使用的框架和您感興趣的運動事件類型而異。

事件沿著特定路徑傳遞,直到它被傳遞到可以處理它的對象。 首先,單例UIApplication對象從隊列的頂部獲取一個事件并分發(fā)處理。 通常,它將事件發(fā)送到應用程序的key window對象,該對象將事件傳遞到初始對象(initial object)進行處理。 初始對象取決于事件的類型。

  • 觸摸事件:對于觸摸事件,窗口對象首先嘗試將事件傳遞到發(fā)生觸摸的視圖。 該視圖稱為命中測試視圖(hit-test view)。 找到命中測試視圖(hit-test view)的過程稱為命中測試(hit-testing),這在Hit-Testing返回觸摸發(fā)生的視圖中描述。

  • 運動和遙控事件:對于這些事件,窗口對象將搖動或遠程控制事件發(fā)送到第一響應者以進行處理。 第一響應者在響應者鏈由響應者對象組成中描述。

這些事件路徑的最終目標是找到一個可以處理和響應事件的對象。 因此,UIKit首先將事件發(fā)送到最適合處理事件的對象。 對于觸摸事件,該對象是命中測試視圖(hit-test view),對于其他事件,該對象是第一個響應者。 以下部分更詳細地說明命中測試視圖(hit-test view)和第一響應者對象是如何確定的。

1. Hit-Testing返回觸摸發(fā)生的視圖

iOS使用命中測試(hit-testing)來查找被觸摸的視圖。 命中測試(hit-testing)涉及檢查觸摸是否在所有相關視圖對象的邊界內。 如果是,它會遞歸檢查視圖的所有子視圖。視圖層級中包含觸摸點的最低的視圖成為命中測試視圖(hit-test view) 。 iOS確定命中測試視圖(hit-test view)后,它會將觸摸事件傳遞到該視圖進行處理。

舉例說明,假設用戶觸摸下圖中的View E。 iOS通過按照此順序檢查子視圖來查找命中測試視圖(hit-test view):

  1. 觸摸在View A的邊界內,因此它檢查子視圖View B和View C.

  2. 觸摸不在View B的界限內,但它在View C的界限內,因此它檢查子視圖View D和View E.

  3. 觸摸不在View D的界限內,但它在View E的界限內。

    View E是視圖層級中包含觸摸的最低的視圖,因此它成為命中測試視圖(hit-test view)。

    Hit-testing returns the subview that was touched

hitTest:withEvent:方法為給定的CGPoint和UIEvent返回一個點擊測試視圖(hit-test view)。hitTest:withEvent:方法首先調用pointInside:withEvent:方法。 如果傳遞到hitTest:withEvent:方法的點是在視圖的邊界內,pointInside:withEvent:返回YES。然后,在每個返回YES的子視圖上遞歸調用hitTest:withEvent:方法 。

如果傳遞到hitTest:withEvent:方法的點不在視圖的邊界內,第一次調用pointInside:withEvent:方法返回 NO ,該點被忽略,hitTest:withEvent:返回nil 。 如果子視圖返回NO,則視圖層級結構的這個整個分支將被忽略,因為如果觸摸沒有發(fā)生在該子視圖中,則它也不會出現在該子視圖的任何子視圖中。這意味著在子視圖內而在父視圖之外的任何點都不能接受點擊事件,因為觸摸點必須在父視圖和子視圖邊界內。如果子視圖的clipsToBounds屬性設置為NO,則可能出現此問題。見示例將事件傳遞給子視圖

注:觸摸對象為其生命周期而關聯到其命中測試視圖(hit-test view),即使觸摸稍后移動到視圖之外。

命中測試視圖(hit-test view)被給予首先處理觸摸事件的機會。 如果命中測試視圖(hit-test view)無法處理的事件,事件沿著響應者鏈向上傳播(如響應者鏈由響應者對象組成中描述),直到系統找到一個可以處理它的對象。

2. 響應者鏈由響應者對象組成

許多類型的事件依賴于為事件傳遞的響應者鏈。 響應鏈是一系列被鏈接起來的響應對象。 它從第一響應者開始,到程序對象(UIApplication object)結束。 如果第一響應者不能處理事件,它轉發(fā)事件到響應者鏈中的下一個響應者。

響應者對象是一個可以響應和處理事件的對象。 UIResponder類是所有響應者對象的基類,它不僅為事件處理定義編程接口,也為常見響應者行為定義編程接口。UIApplication, UIViewController和UIView類的實例都是響應者(responder),這意味著所有的視圖和大多數控制器對象都是響應者。 注意核心動畫層不是響應者。

第一個響應者被指定為第一個接收事件。 通常,第一響應者是視圖對象。 一個對象通過做兩件事情成為第一個響應者:

  1. 重寫canBecomeFirstResponder方法返回YES。
  2. 接收becomeFirstResponder消息。 如果需要,對象可以向自身發(fā)送此消息。

注:請確保您的應用程序在指派一個對象成為第一個響應者之前已經建立了對象圖(has established its object graph,個人感覺應該理解為對象已經被渲染完成)。 例如,您通常在重寫的viewDidAppear:方法中調用becomeFirstResponder方法。 如果您嘗試在viewWillAppear:中指派第一響應者,你的對象圖尚未建立(object graph is not yet established,個人理解為對象渲染尚未完成),所以becomeFirstResponder方法返回 NO 。

事件不是唯一依賴響應者鏈的對象,響應者鏈用于以下所有情況:

  • 觸摸事件(Touch events):如果命中測試視圖(hit-test view)不能夠處理觸摸事件,事件以命中測試視圖(hit-test view)為起點沿著響應者鏈向上傳遞。
  • 運動事件(Motion events):為了使用UIKit處理搖動動作事件,第一響應者必須實現UIResponder類的motionBegan:withEvent:motionEnded:withEvent:的方法。
  • 遙控事件(Remote control event):為了處理遙控事件,第一響應者必須實現UIResponder類的remoteControlReceivedWithEvent:方法。
  • 動作消息(Action messages):當用戶操作一個控制對象,例如一個按鈕(button)或者開關(switch),并且動作方法(action method)的目標(target)是nil,則消息以控制視圖為起點沿著響應者鏈傳遞。參閱示例:將事件傳遞給父視圖
  • 編輯菜單消息(Editing-menu messages):當用戶點擊編輯菜單中的命令,iOS使用響應者鏈找到實現了必要方法的對象(如cut: ,copy:paste: )。 想了解更多信息,請參閱顯示和管理編輯菜單
  • 文本編輯(Text editing):當用戶點擊text field或text view,該視圖自動成為第一個響應者。 默認情況下,虛擬鍵盤出現,text field或text view成為編輯的焦點。您可以顯示自定義輸入視圖,而不是鍵盤。 您還可以向任何響應者對象添加自定義輸入視圖。 想了解更多信息,請參閱自定義數據輸入視圖

UIKit自動設置用戶點擊的text field或text view為第一個響應者; 應用程序必須使用becomeFirstResponder方法顯式設置所有其他對象為第一響應者。

3. 響應者鏈遵循特定傳遞路徑

如果初始對象(命中測試視圖或第一個響應者)不處理事件,UIKit將事件傳遞給鏈中的下一個響應者。 每個響應者決定是否它要處理事件或通過調用其nextRsponder方法傳遞給它自己的下一個響應者。這種處理持續(xù)進行,直到一個響應者對象處理事件或有沒有更多的響應者。

當iOS檢測到事件并將其傳遞給初始對象(通常是視圖)時,響應者鏈序列開始。 初始視圖擁有第一機會處理事件。下圖顯示了兩個不同配置應用程序的兩個不同事件傳遞路徑。應用程序的事件傳遞路徑取決于其特定結構,但所有事件傳遞路徑都遵循相同的探視程序。

The responder chain on iOS

對于左側的應用程序,事件遵循以下路徑:

  1. 初始視圖試圖處理該事件或消息。如果它不能處理這個事件,它將事件傳遞到其父視圖 ,因為初始視圖在它的視圖控制器的視圖層次中不是最頂部的視圖。
  2. 父視圖嘗試處理該事件。如果父視圖不能處理事件,它將事件傳遞到其超級視圖,因為它仍然不是視圖層次中最頂部的視圖。
  3. 視圖控制器的視圖層次中最頂層視圖嘗試處理該事件。如果最頂層的視圖不能處理事件,它將事件傳遞到它的視圖控制器。
  4. 視圖控制器嘗試處理該事件,如果不能,將事件傳遞到窗口。
  5. 如果窗口對象不能處理該事件,傳遞事件到單例應用程序對象。
  6. 如果應用程序對象不能處理這個事件,它丟棄該事件。

右側的應用程序遵循稍微不同的路徑,但所有事件傳遞路徑遵循以下探視程序:

  1. 視圖在其視圖控制器的視圖層次結構上向上傳遞事件,直到它到達最頂層視圖。

  2. 最頂層視圖將事件傳遞到其視圖控制器。

  3. 視圖控制器將事件傳遞到其最頂層視圖的父視圖。

    重復步驟1-3,直到事件到達根視圖控制器。

  4. 根視圖控制器將事件傳遞到窗口對象。

  5. 窗口將事件傳遞給應用程序對象。

重要提示:如果您實現一個自定義視圖來處理遙控事件,動作消息,UIKit的搖移動事件,或編輯菜單消息,不要直接轉發(fā)事件或消息到nextResponder來沿響應者鏈向上傳遞。 相反,調用當前事件處理方法的超類實現,讓UIKit處理響應者鏈的遍歷。

三、應用

從事件傳遞與響應者鏈的內容思考一些應用例子。

1. 擴大視圖的點擊區(qū)域

一個按鈕的尺寸是20*20,如果要擴大按鈕的點擊區(qū)域(上下左右各擴大10),有以下處理方法:

  • 按鈕設置image,然后按鈕的size設置的比實際大一倍。
  • 在按鈕上覆蓋一層較大的View或者Button,設置點擊事件。
  • 自定義Button,覆蓋hitTest:withEvent:或者pointInside:withEvent:方法。

我們只舉例說明第三種方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    if (self.userInteractionEnabled == NO || self.hidden || self.alpha <= 0.01) {
        return nil;
    }
    
    CGRect responseRect = CGRectInset(self.bounds, -10, -10);
    if (CGRectContainsPoint(responseRect, point)) {
        for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subView convertPoint:point fromView:self];
            UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

或者

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s", __PRETTY_FUNCTION__);   
    CGRect bounds = CGRectInset(self.bounds, -10, -10);
    return CGRectContainsPoint(bounds, point);
}

2. 將事件傳遞給父視圖

在controller中有一個YKNoteEventHandingView,其上面再添加一個YKNoteEventHandlingButton,點擊Button將事件傳遞到View。有以下幾種做法:

  • Button的- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法返回nil,hit-test view為父視圖

  • YKNoteEventHandingView的- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法返回self,阻止事件傳遞給子視圖

  • 設置Button的target為nil,Button無法處理事件響應,事件沿著響應者鏈向上傳遞,傳遞到父視圖。示例如下

#import "YKNoteEventHandingView.h"

@implementation YKNoteEventHandingView
//在View中寫一個action方法,判斷View中的Button的target為nil的時候是否會執(zhí)行,若執(zhí)行,則消息沿著響應者鏈向上傳遞了
- (void)ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button {
    NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);
}

@end
  
#import "YKNoteEventHandlingButton.h"
//在Button中寫一個action方法,判斷Button的target為nil的時候是否會執(zhí)行,若執(zhí)行,則消息沿著響應者鏈傳遞了
@implementation YKNoteEventHandlingButton

- (void)ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button {
    NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);
}
#import "YKNoteEventHandingViewController.h"
#import "YKNoteEventHandingView.h"
#import "YKNoteEventHandlingButton.h"

@interface YKNoteEventHandingViewController ()

@property (nonatomic, strong) YKNoteEventHandingView *yKNoteEventHandingView;
@property (nonatomic, strong) YKNoteEventHandlingButton *ykNoteEventHandlingButton;

@end

@implementation YKNoteEventHandingViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.title = @"EventHandling";
    self.view.backgroundColor = [UIColor whiteColor];
    //View
    [self.yKNoteEventHandingView setFrame:CGRectMake(50, 100, 200, 200)];
    [self.view addSubview:self.yKNoteEventHandingView];

    //Button
    [self.ykNoteEventHandlingButton setFrame:CGRectMake(60, 60, 100, 100)];
    [self.yKNoteEventHandingView addSubview:self.ykNoteEventHandlingButton];
}

#pragma mark - event
- (void)ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button {
    NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);
}

#pragma mark - getter
- (YKNoteEventHandingView *)yKNoteEventHandingView {
    if (_yKNoteEventHandingView == nil) {
        _yKNoteEventHandingView = [[YKNoteEventHandingView alloc] init];
        _yKNoteEventHandingView.backgroundColor = [UIColor redColor];
    }
    return _yKNoteEventHandingView;
}

- (YKNoteEventHandlingButton *)ykNoteEventHandlingButton {
    if (_ykNoteEventHandlingButton == nil) {
        _ykNoteEventHandlingButton = [[YKNoteEventHandlingButton alloc] init];
        _ykNoteEventHandlingButton.backgroundColor = [UIColor greenColor];
        [_ykNoteEventHandlingButton addTarget:nil action:@selector(ykNoteEventHandlingGreenButtonDidTouchUpInside:) forControlEvents:UIControlEventTouchUpInside];
    }
    return _ykNoteEventHandlingButton;
}
  //Button的target設置為nil的時候,執(zhí)行了YKNoteEventHandlingButton中的方法,說明target為nil的時候事件沿著響應者鏈傳遞了
  -[YKNoteEventHandlingButton ykNoteEventHandlingGreenButtonDidTouchUpInside:] 
   <YKNoteEventHandlingButton: 0x100224950; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x17002a1a0>>

  //注釋掉Button中的方法。輸出內容如下,說明事件沿著響應者鏈向上傳遞了。
  -[YKNoteEventHandingView ykNoteEventHandlingGreenButtonDidTouchUpInside:] 
   <YKNoteEventHandlingButton: 0x10030fe40; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x17003d520>>

  //注釋掉Button和View中的方法。輸出內容如下,說明事件沿著響應者鏈向上傳遞了。
  -[YKNoteEventHandingViewController ykNoteEventHandlingGreenButtonDidTouchUpInside:] 
   <YKNoteEventHandlingButton: 0x100402fd0; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x1740315a0>>

3. 將事件傳遞給兄弟視圖

假設有下圖所示的布局,我們希望點擊view C的時候view B響應事件,而點擊View D和View E的時候正常響應。這個時候通過重寫view C的hittest可以解決這個問題,在C的hittest里面直接返回nil就行了。

Hit-testing returns the subview that was touched
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView == self) {
        return nil;
    }
    return hitTestView;    
}

4. 將事件傳遞給子視圖

如下圖,banner為CollectionView中的一個樓層,CollectionViewCell中有個scrollView,scrollView中為圖片,現在將cell的寬度縮小一半(變?yōu)樗{色框部分),設置cell和scrollview的clipsToBounds為NO,現在在右側處滑動,scrollview中的圖片顯然不會滑動,因為不滿足pointInside:withEvent:,這時只需要修改cell的- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法,返回scrollview即可。

傳遞事件到子視圖
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView == nil) {
        hitTestView = self.scrollView;
    }
    return hitTestView;
}

參考:

https://developer.apple.com/library/content/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/Introduction/Introduction.html

https://developer.apple.com/library/content/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/event_delivery_responder_chain/event_delivery_responder_chain.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容