在iOS中不是所有的對象都能處理事件,只有繼承了UIResponder的對象才能接收並處理事件,稱之為響應者對象;
UIApplication、UIViewController、UIView都繼承自UIResponder,因此它們都是響應者對象,都能接收並處理事件;
UIEvent
是由硬件捕獲到的一個表示用戶操作設備的對象,事件分為三類:觸摸事件、晃動事件、遠程控制事件,一個觸摸事件包含一個或者多個手指,每個手指是一個UITouch對象;每產生一個事件,就會產生一個UIEvent對象,用於記錄事件產生的時刻和類型;
常用屬性:
UIEvnetType type;
UIEventSubType subtype;
NSTimeInterval timestamp;
UIResponder
響應者是可以響應用戶事件並且可以對其進行處理的對象;UIResponder是響應者的基類,UIApplication、UIView和所有從UIView繼承的子類都可以捕獲到用戶事件(不是所有的類都可以捕獲到用戶事件);
第一響應者是指當前正在接收事件的響應者對象。要捕獲用戶事件,需要實現touchesBegan,touchesMoves,touchesEnded等方法;
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
用戶的觸摸操作事件對象存放在touches集合里面,里面存放的是UITouch手指對象;
在上面4個觸摸事件處理方法中,都有兩個參數,一個完整的觸摸過程,會經歷3個狀態,整個過程中只會產生一個事件對象UIEvent,4個觸摸方法都是同一個UIEvent參數;
如果兩個手指同時觸摸一個view,那么view只會調用1次touchesBegin,參數touches里面有2個UITouch對象;
如果兩個手指一前一后分開觸摸,則view會分別調用2次touchesBegin,每次調用時的touches參數中只包含1個UITouch對象;
UITouch
表示觸摸事件中的一個手指;UITouch支持觸摸事件,並且支持多點觸摸;
-locationInView: // 當前手指在所指定的view中的位置 -previousLocationInView: // 上一次手指在所指定的view中的位置 view // 所在的view window // 所在的窗口對象 tapCount // 獲取手指點擊次數
觸碰檢測(Hit-Test)
通過觸碰檢測找出Touch事件發生在哪個視圖上。遞歸檢測所有的子視圖,定位觸碰點的位置;當一個觸碰事件發生時,從根視圖開始,逐個檢測判斷觸碰是否發生在某個子視圖上,如果該子視圖沒有子視圖,則事件發生在該視圖上;
/** * 此方法會返回能夠處理事件的控件,可以用來攔截所有的觸摸事件; * 如果返回nil,則表示該控件不能處理事件,則事件會向上傳遞;反之則不會再傳遞事件; */ -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if([self findLink:point]) return self; return nil; } //- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event //{ // if ([self touchingLinkWithPoint:point]) { // return YES; // } // return NO; //}
響應者鏈
由很多響應者鏈接在一起組合起來的一個鏈條稱之為響應者鏈條;響應者鏈定義了iOS中觸摸事件的交互規則,如果hit-test檢測出的view沒有響應事件,事件就沿着響應者鏈往上傳遞;如果鏈中的某個響應者響應了事件或者已經沒有響應者了,傳遞結束;
具體來講,如果一個UIView沒有定義touchesBegan等方法,則事件會向父View傳遞直到UIViewController的根視圖,如果都沒有定義事件響應方法,則再一直傳遞下去直到window,UIApplication,最后丟棄事件;
一次完整的觸摸事件的傳遞響應過程
UIApplication –> UIWindow –> 遞歸找到最適合處理事件的控件
控件調用touches方法 –> 判斷是否實現touches方法 –> 如果沒有實現默認會將事件傳遞給上一個響應者 –> 找到上一個響應者
默認做法是將事件順着響應者鏈條向上傳遞,將事件交給上一個響應者進行處理;
如何判斷當前響應者的上一個響應者是誰?
判斷當前響應者是否是控制器的view,如果是控制器的view,上一個響應者就是控制器;
如果當前響應者不是控制器的view,上一個響應者就是父控件;
響應者鏈條作用?
可以讓一個觸摸事件發生的時候讓多個響應者同時響應該事件;通過調用[super touchesXxx];
調整事件傳遞
對於UIView,將userInteractionEnabled屬性設置為NO,或者當視圖hidden屬性為YES或者alpha為0時,視圖也不能接收事件;
對於應用程序來說,可以通過UIApplication的beginIgnoringInteractionEvents和endIgnoringInteractionEvnets來關閉或者開啟事件傳遞;
多點觸摸
在UIView中將multipleTouchEnabled屬性設置為YES;如果多點觸摸已經打開,則在touches和UIEvent中則會存在多個UITouch對象,否則在touches中只會有一個UITouch對象。
我們的App與用戶進行交互,基本上是依賴於各種各樣的事件。例如,用戶點擊界面上的按鈕,我們需要觸發一個按鈕點擊事件,並進行相應的處理,以給用戶一個響應。UIView的三大職責之一就是處理事件,一個視圖是一個事件響應者,可以處理點擊等事件,而這些事件就是在UIResponder類中定義的。
一個UIResponder類為那些需要響應並處理事件的對象定義了一組接口。這些事件主要分為兩類:觸摸事件(touch events)和運動事件(motion events)。UIResponder類為每兩類事件都定義了一組接口,這個我們將在下面詳細描述。
在UIKit中,UIApplication、UIView、UIViewController這幾個類都是直接繼承自UIResponder類。另外SpriteKit中的SKNode也是繼承自UIResponder類。因此UIKit中的視圖、控件、視圖控制器,以及我們自定義的視圖及視圖控制器都有響應事件的能力。這些對象通常被稱為響應對象,或者是響應者(以下我們統一使用響應者)。
本文將詳細介紹一個UIResponder類提供的基本功能。不過在此之前,我們先來了解一下事件響應鏈機制。
響應鏈
大多數事件的分發都是依賴響應鏈的。響應鏈是由一系列鏈接在一起的響應者組成的。一般情況下,一條響應鏈開始於第一響應者,結束於application對象。如果一個響應者不能處理事件,則會將事件沿着響應鏈傳到下一響應者。
那這里就會有三個問題:
- 響應鏈是何時構建的
- 系統是如何確定第一響應者的
- 確定第一響應者后,系統又是按照什么樣的順序來傳遞事件的
構建響應鏈
我們都知道在一個App中,所有視圖是按一定的結構組織起來的,即樹狀層次結構。除了根視圖外,每個視圖都有一個父視圖;而每個視圖都可以有0個或多個子視圖。而在這個樹狀結構構建的同時,也構建了一條條的事件響應鏈。
確定第一響應者
當用戶觸發某一事件(觸摸事件或運動事件)后,UIKit會創建一個事件對象(UIEvent),該對象包含一些處理事件所需要的信息。然后事件對象被放到一個事件隊列中。這些事件按照先進先出的順序來處理。當處理事件時,程序的UIApplication對象會從隊列頭部取出一個事件對象,將其分發出去。通常首先是將事件分發給程序的主window對象,對於觸摸事件來講,window對象會首先嘗試將事件分發給觸摸事件發生的那個視圖上。這一視圖通常被稱為hit-test視圖,而查找這一視圖的過程就叫做hit-testing。
系統使用hit-testing來找到觸摸下的視圖,它檢測一個觸摸事件是否發生在相應視圖對象的邊界之內(即視圖的frame屬性,這也是為什么子視圖如果在父視圖的frame之外時,是無法響應事件的)。如果在,則會遞歸檢測其所有的子視圖。包含觸摸點的視圖層次架構中最底層的視圖就是hit-test視圖。在檢測出hit-test視圖后,系統就將事件發送給這個視圖來進行處理。
我們通過一個示例來演示hit-testing的過程。圖1是一個視圖層次結構,
假設用戶點擊了視圖E,系統按照以下順序來查找hit-test視圖:
- 點擊事件發生在視圖A的邊界內,所以檢測子視圖B和C;
- 點擊事件不在視圖B的邊界內,但在視圖C的邊界范圍內,所以檢測子圖片D和E;
- 點擊事件不在視圖D的邊界內,但在視圖E的邊界范圍內;
視圖E是包含觸摸點的視圖層次架構中最底層的視圖(倒樹結構),所以它就是hit-test視圖。
hit-test視圖可以最先去處理觸摸事件,如果hit-test視圖不能處理事件,則事件會沿着響應鏈往上傳遞,直到找到能處理它的視圖。
事件傳遞
最有機會處理事件的對象是hit-test視圖或第一響應者。如果這兩者都不能處理事件,UIKit就會將事件傳遞到響應鏈中的下一個響應者。每一個響應者確定其是否要處理事件或者是通過nextResponder方法將其傳遞給下一個響應者。這一過程一直持續到找到能處理事件的響應者對象或者最終沒有找到響應者。
圖2演示了這樣一個事件傳遞的流程,
當系統檢測到一個事件時,將其傳遞給初始對象,這個對象通常是一個視圖。然后,會按以下路徑來處理事件(我們以左圖為例):
- 初始視圖(initial view)嘗試處理事件。如果它不能處理事件,則將事件傳遞給其父視圖。
- 初始視圖的父視圖(superview)嘗試處理事件。如果這個父視圖還不能處理事件,則繼續將視圖傳遞給上層視圖。
- 上層視圖(topmost view)會嘗試處理事件。如果這個上層視圖還是不能處理事件,則將事件傳遞給視圖所在的視圖控制器。
- 視圖控制器會嘗試處理事件。如果這個視圖控制器不能處理事件,則將事件傳遞給窗口(window)對象。
- 窗口(window)對象嘗試處理事件。如果不能處理,則將事件傳遞給單例app對象。
- 如果app對象不能處理事件,則丟棄這個事件。
從上面可以看到,視圖、視圖控制器、窗口對象和app對象都能處理事件。另外需要注意的是,手勢也會影響到事件的傳遞。
以上便是響應鏈的一些基本知識。有了這些知識,我們便可以來看看UIResponder提供給我們的一些方法了。
管理響應鏈
UIResponder提供了幾個方法來管理響應鏈,包括讓響應對象成為第一響應者、放棄第一響應者、檢測是否是第一響應者以及傳遞事件到下一響應者的方法,我們分別來介紹一下。
上面提到在響應鏈中負責傳遞事件的方法是nextResponder,其聲明如下:
- (UIResponder *)nextResponder;
UIResponder類並不自動保存或設置下一個響應者,該方法的默認實現是返回nil。子類的實現必須重寫這個方法來設置下一響應者。UIView的實現是返回管理它的UIViewController對象(如果它有)或者其父視圖。而UIViewController的實現是返回它的視圖的父視圖;UIWindow的實現是返回app對象;而UIApplication的實現是返回nil。所以,響應鏈是在構建視圖層次結構時生成的。
一個響應對象可以成為第一響應者,也可以放棄第一響應者。為此,UIResponder提供了一系列方法,我們分別來介紹一下。
如果想判定一個響應對象是否是第一響應者,則可以使用以下方法:
- (BOOL)isFirstResponder;
如果我們希望將一個響應對象作為第一響應者,則可以使用以下方法:
- (BOOL)becomeFirstResponder;
如果對象成為第一響應者,則返回YES;否則返回NO。默認實現是返回YES。子類可以重寫這個方法來更新狀態,或者來執行一些其它的行為。
一個響應對象只有在當前響應者能放棄第一響應者狀態(canResignFirstResponder)且自身能成為第一響應者(canBecomeFirstResponder)時才會成為第一響應者。
這個方法相信大家用得比較多,特別是在希望UITextField獲取焦點時。另外需要注意的是只有當視圖是視圖層次結構的一部分時才調用這個方法。如果視圖的window屬性不為空時,視圖才在一個視圖層次結構中;如果該屬性為nil,則視圖不在任何層次結構中。
上面提到一個響應對象成為第一響應者的一個前提是它可以成為第一響應者,我們可以使用canBecomeFirstResponder方法來檢測,
- (BOOL)canBecomeFirstResponder;
需要注意的是我們不能向一個不在視圖層次結構中的視圖發送這個消息,其結果是未定義的。
與上面兩個方法相對應的是響應者放棄第一響應者的方法,其定義如下:
- (BOOL)resignFirstResponder;
- (BOOL)canResignFirstResponder;
resignFirstResponder默認也是返回YES。需要注意的是,如果子類要重寫這個方法,則在我們的代碼中必須調用super的實現。
canResignFirstResponder默認也是返回YES。不過有些情況下可能需要返回NO,如一個輸入框在輸入過程中可能需要讓這個方法返回NO,以確保在編輯過程中能始終保證是第一響應者。
管理輸入視圖
所謂的輸入視圖,是指當對象為第一響應者時,顯示另外一個視圖用來處理當前對象的信息輸入,如UITextView和UITextField兩個對象,在其成為第一響應者是,會顯示一個系統鍵盤,用來輸入信息。這個系統鍵盤就是輸入視圖。輸入視圖有兩種,一個是inputView,另一個是inputAccessoryView。這兩者如圖3所示:
與inputView相關的屬性有如下兩個,
@property(nonatomic, readonly, retain) UIView *inputView; @property(nonatomic, readonly, retain) UIInputViewController *inputViewController;
這兩個屬性提供一個視圖(或視圖控制器)用於替代為UITextField和UITextView彈出的系統鍵盤。我們可以在子類中將這兩個屬性重新定義為讀寫屬性來設置這個屬性。如果我們需要自己寫一個鍵盤的,如為輸入框定義一個用於輸入身份證的鍵盤(只包含0-9和X),則可以使用這兩個屬性來獲取這個鍵盤。
與inputView類似,inputAccessoryView也有兩個相關的屬性:
@property(nonatomic, readonly, retain) UIView *inputAccessoryView; @property(nonatomic, readonly, retain) UIInputViewController *inputAccessoryViewController;
設置方法與前面相同,都是在子類中重新定義為可讀寫屬性,以設置這個屬性。
另外,UIResponder還提供了以下方法,在對象是第一響應者時更新輸入和訪問視圖,
- (void)reloadInputViews;
調用這個方法時,視圖會立即被替換,即不會有動畫之類的過渡。如果當前對象不是第一響應者,則該方法是無效的。
響應觸摸事件
UIResponder提供了如下四個大家都非常熟悉的方法來響應觸摸事件:
// 當一個或多個手指觸摸到一個視圖或窗口 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; // 當與事件相關的一個或多個手指在視圖或窗口上移動時 - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event; // 當一個或多個手指從視圖或窗口上抬起時 - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event; // 當一個系統事件取消一個觸摸事件時 - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
這四個方法默認都是什么都不做。不過,UIKit中UIResponder的子類,尤其是UIView,這幾個方法的實現都會把消息傳遞到響應鏈上。因此,為了不阻斷響應鏈,我們的子類在重寫時需要調用父類的相應方法;而不要將消息直接發送給下一響應者。
默認情況下,多點觸摸是被禁用的。為了接受多點觸摸事件,我們需要設置響應視圖的multipleTouchEnabled屬性為YES。
響應移動事件
與觸摸事件類似,UIResponder也提供了幾個方法來響應移動事件:
// 移動事件開始 - (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event; // 移動事件結束 - (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event; // 取消移動事件 - (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;
與觸摸事件不同的是,運動事件只有開始與結束操作;它不會報告類似於晃動這樣的事件。這幾個方法的默認操作也是什么都不做。不過,UIKit中UIResponder的子類,尤其是UIView,這幾個方法的實現都會把消息傳遞到響應鏈上。
響應遠程控制事件
遠程控制事件來源於一些外部的配件,如耳機等。用戶可以通過耳機來控制視頻或音頻的播放。接收響應者對象需要檢查事件的子類型來確定命令(如播放,子類型為UIEventSubtypeRemoteControlPlay),然后進行相應處理。
為了響應遠程控制事件,UIResponder提供了以下方法,
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;
我們可以在子類中實現該方法,來處理遠程控制事件。不過,為了允許分發遠程控制事件,我們必須調用UIApplication的beginReceivingRemoteControlEvents方法;而如果要關閉遠程控制事件的分發,則調用endReceivingRemoteControlEvents方法。
獲取Undo管理器
默認情況下,程序的每一個window都有一個undo管理器,它是一個用於管理undo和redo操作的共享對象。然而,響應鏈上的任何對象的類都可以有自定義undo管理器。例如,UITextField的實例的自定義管理器在文件輸入框放棄第一響應者狀態時會被清理掉。當需要一個undo管理器時,請求會沿着響應鏈傳遞,然后UIWindow對象會返回一個可用的實例。
UIResponder提供了一個只讀方法來獲取響應鏈中共享的undo管理器,
@property(nonatomic, readonly) NSUndoManager *undoManager;
我們可以在自己的視圖控制器中添加undo管理器來執行其對應的視圖的undo和redo操作。
驗證命令
在我們的應用中,經常會處理各種菜單命令,如文本輸入框的”復制”、”粘貼”等。UIResponder為此提供了兩個方法來支持此類操作。首先使用以下方法可以啟動或禁用指定的命令:
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender;
該方法默認返回YES,我們的類可以通過某種途徑處理這個命令,包括類本身或者其下一個響應者。子類可以重寫這個方法來開啟菜單命令。例如,如果我們希望菜單支持”Copy”而不支持”Paser”,則在我們的子類中實現該方法。需要注意的是,即使在子類中禁用某個命令,在響應鏈上的其它響應者也可能會處理這些命令。
另外,我們可以使用以下方法來獲取可以響應某一行為的接收者:
- (id)targetForAction:(SEL)action withSender:(id)sender;
在對象需要調用一個action操作時調用該方法。默認的實現是調用canPerformAction:withSender:方法來確定對象是否可以調用action操作。如果可以,則返回對象本身,否則將請求傳遞到響應鏈上。如果我們想要重寫目標的選擇方式,則應該重寫這個方法。下面這段代碼演示了一個文本輸入域禁用拷貝/粘貼操作:
- (id)targetForAction:(SEL)action withSender:(id)sender { UIMenuController *menuController = [UIMenuController sharedMenuController]; if (action == @selector(selectAll:) || action == @selector(paste:) ||action == @selector(copy:) || action == @selector(cut:)) { if (menuController) { [UIMenuController sharedMenuController].menuVisible = NO; } return nil; } return [super targetForAction:action withSender:sender]; }
訪問快捷鍵命令
我們的應用可以支持外部設備,包括外部鍵盤。在使用外部鍵盤時,使用快捷鍵可以大大提高我們的輸入效率。因此從iOS7后,UIResponder類新增了一個只讀屬性keyCommands,來定義一個響應者支持的快捷鍵,其聲明如下:
@property(nonatomic, readonly) NSArray *keyCommands;
一個支持硬件鍵盤命令的響應者對象可以重新定義這個方法並使用它來返回一個其所支持快捷鍵對象(UIKeyCommand)的數組。每一個快捷鍵命令表示識別的鍵盤序列及響應者的操作方法。
我們用這個方法返回的快捷鍵命令數組被用於整個響應鏈。當與快捷鍵命令對象匹配的快捷鍵被按下時,UIKit會沿着響應鏈查找實現了響應行為方法的對象。它調用找到的第一個對象的方法並停止事件的處理。
管理文本輸入模式
文本輸入模式標識當響應者激活時的語言及顯示的鍵盤。UIResponder為此定義了一個屬性來返回響應者對象的文本輸入模式:
@property(nonatomic, readonly, retain) UITextInputMode *textInputMode;
對於響應者而言,系統通常顯示一個基於用戶語言設置的鍵盤。我們可以重新定義這個屬性,並讓它返回一個不同的文本輸入模式,以讓我們的響應者使用一個特定的鍵盤。用戶在響應者被激活時仍然可以改變鍵盤,在切換到另一個響應者時,可以再恢復到指定的鍵盤。
如果我們想讓UIKit來跟蹤這個響應者的文本輸入模式,我們可以通過textInputContextIdentifier屬性來設置一個標識,該屬性的聲明如下:
@property(nonatomic, readonly, retain) NSString *textInputContextIdentifier;
該標識指明響應者應保留文本輸入模式的信息。在跟蹤模式下,任何對文本輸入模式的修改都會記錄下來,當響應者激活時再用於恢復處理。
為了從程序的user default中清理輸入模式信息,UIResponder定義了一個類方法,其聲明如下:
+ (void)clearTextInputContextIdentifier:(NSString *)identifier;
調用這個方法可以從程序的user default中移除與指定標識相關的所有文本輸入模式。移除這些信息會讓響應者重新使用默認的文本輸入模式。
支持User Activities
從iOS 8起,蘋果為我們提供了一個非常棒的功能,即Handoff。使用這一功能,我們可以在一部iOS設備的某個應用上開始做一件事,然后在另一台iOS設備上繼續做這件事。Handoff的基本思想是用戶在一個應用里所做的任何操作都可以看作是一個Activity,一個Activity可以和一個特定iCloud用戶的多台設備關聯起來。在編寫一個支持Handoff的應用時,會有以下三個交互事件:
- 為將在另一台設備上繼續做的事創建一個新的User Activity;
- 當需要時,用新的數據更新已有的User Activity;
- 把一個User Activity傳遞到另一台設備上。
為了支持這些交互事件,在iOS 8后,UIResponder類新增了幾個方法,我們在此不討論這幾個方法的實際使用,想了解更多的話,可以參考 iOS 8 Handoff 開發指南 。我們在此只是簡單描述一下這幾個方法。
在UIResponder中,已經為我們提供了一個userActivity屬性,它是一個NSUserActivity對象。因此我們在UIResponder的子類中不需要再去聲明一個userActivity屬性,直接使用它就行。其聲明如下:
@property(nonatomic, retain) NSUserActivity *userActivity;
由UIKit管理的User Activities會在適當的時間自動保存。一般情況下,我們可以重寫UIResponder類的updateUserActivityState:方法來延遲添加表示User Activity的狀態數據。當我們不再需要一個User Activity時,我們可以設置userActivity屬性為nil。任何由UIKit管理的NSUserActivity對象,如果它沒有相關的響應者,則會自動失效。
另外,多個響應者可以共享一個NSUserActivity實例。
上面提到的updateUserActivityState:是用於更新給定的User Activity的狀態。其定義如下:
- (void)updateUserActivityState:(NSUserActivity *)activity;
子類可以重寫這個方法來按照我們的需要更新給定的User Activity。我們需要使用NSUserActivity對象的addUserInfoEntriesFromDictionary:方法來添加表示用戶Activity的狀態。
在我們修改了User Activity的狀態后,如果想將其恢復到某個狀態,則可以使用以下方法:
- (void)restoreUserActivityState:(NSUserActivity *)activity;
子類可以重寫這個方法來使用給定User Activity的恢復響應者的狀態。系統會在接收到數據時,將數據傳遞給application:continueUserActivity:restorationHandler:以做處理。我們重寫時應該使用存儲在user activity的userInfo字典中的狀態數據來恢復對象。當然,我們也可以直接調用這個方法。