iOS事件傳遞和事件響應鏈
前言
當用戶對view進行觸控時,便會產生事件,執行我們的業務操作。我們的每一個事件,在iOS系統都會經過傳遞和響應的過程。
事件產生後,經過層層傳遞,直到找到最合適的檢視後,再逐層返回直到有事件響應操作。
事件的定義
iOS中的事件可以分為3大型別:
- 觸控事件
- 加速計事件
遠端控制事件
這裡我們只討論iOS中的觸控事件。
1.響應者物件(UIResponder)
在iOS中不是任何物件都能處理事件,只有繼承了UIResponder的物件才能接受並處理事件,我們稱之為“響應者物件”。以下都是繼承自UIResponder的,所以都能接收並處理事件。
- UIApplication
- UIViewController
- UIView
UIResponder中提供了以下4個物件方法來處理觸控事件。
//UIResponder內部提供了以下方法來處理事件觸控事件
// 一根或者多根手指開始觸控view,系統會自動呼叫view的下面方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
// 一根或者多根手指在view上移動,系統會自動呼叫view的下面方法(隨著手指的移動,會持續呼叫該方法)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
// 一根或者多根手指離開view,系統會自動呼叫view的下面方法
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
// 觸控結束前,某個系統事件(例如電話呼入)會打斷觸控過程,系統會自動呼叫view的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
//加速計事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void) motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;
//遠端控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;
通過繼承UIResponder後重寫事件方法,可以用於處理事件,都是由系統自動呼叫。
其中,touches中存放的都是UITouch物件
2.UITouch
當用戶用一根手指觸控式螢幕幕時,會建立一個與手指相關的UITouch物件
一根手指對應一個UITouch物件
如果兩根手指同時觸控一個view,那麼view只會呼叫一次touchesBegan:withEvent:方法,touches引數中裝著2個UITouch物件
如果這兩根手指一前一後分開觸控同一個view,那麼view會分別呼叫2次touchesBegan:withEvent:方法,並且每次呼叫時的touches引數中只包含一個UITouch物件
它儲存著跟手指相關的資訊,比如觸控的位置、時間、階段。
當手指移動時,系統會更新同一個UITouch物件,使之能夠一直儲存該手指在的觸控位置。
當手指離開螢幕時,系統會銷燬相應的UITouch物件。
UITouch的屬性:
觸控產生時所處的視窗
@property(nonatomic,readonly,retain) UIWindow *window;
觸控產生時所處的檢視
@property(nonatomic,readonly,retain) UIView *view
;
短時間內點按螢幕的次數,可以根據tapCount判斷單擊、雙擊或更多的點選
@property(nonatomic,readonly) NSUInteger tapCount;
記錄了觸控事件產生或變化時的時間,單位是秒@property(nonatomic,readonly) NSTimeInterval timestamp;
當前觸控事件所處的狀態
@property(nonatomic,readonly) UITouchPhase phase;
UITouch的方法:
-(CGPoint)locationInView:(UIView *)view;
// 返回值表示觸控在view上的位置
// 這裡返回的位置是針對view的座標系的(以view的左上角為原點(0, 0))
// 呼叫時傳入的view引數為nil的話,返回的是觸控點在UIWindow的位置
-(CGPoint)previousLocationInView:(UIView *)view;
// 該方法記錄了前一個觸控點的位置
事件的傳遞
當發生觸控事件後,系統會將該事件加入到一個由UIApplication管理的事件佇列中,將事件分發下去以便處理。而這個處理過程就是一個傳遞事件尋找最合適view的過程。
通常,先發送事件給應用程式的主視窗(keyWindow)。
主視窗會在檢視層次結構中找到一個最合適的檢視來處理觸控事件,這也是整個事件處理過程的第一步。
找到合適的檢視控制元件後,就會呼叫檢視控制元件的touches方法來作具體的事件處理。
這個過程是由上到下的傳遞過程,
- 觸控事件的傳遞是從父控制元件傳遞到子控制元件
- 也就是UIApplication->window->尋找處理事件最合適的view
尋找事件最合適的view
其實可以說,事件傳遞的過程其實就是一個尋找最合適檢視的過程。
那麼應用如何找到最合適的控制元件的?
1.首先判斷主視窗(keyWindow)自己是否能接受觸控事件
2.判斷觸控點是否在自己身上
3.子控制元件陣列中從後往前遍歷子控制元件,重複前面的兩個步驟(所謂從後往前遍歷子控制元件,就是首先查詢子控制元件陣列中最後一個元素,因為後新增的子控制元件在上面,降低迴圈次數,然後執行1、2步驟)
4.view,比如叫做fitView,那麼會把這個事件交給這個fitView,再遍歷這個fitView的子控制元件,直至沒有更合適的view為止。
5.如果沒有符合條件的子控制元件,那麼就認為自己最合適處理這個事件,也就是自己是最合適的view。
注 意: 如果父控制元件不能接受觸控事件,那麼子控制元件就不可能接收到觸控事件
UIView不能接收觸控事件有三種情況:
- 不允許互動:userInteractionEnabled = NO
- 隱藏:如果把父控制元件隱藏,那麼子控制元件也會隱藏,隱藏的控制元件不能接受事件
- 透明度:如果設定一個控制元件的透明度
- (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.從後往前遍歷自己的子控制元件
NSInteger count = self.subviews.count;
for (NSInteger 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) { // 尋找到最合適的view
return fitView;
}
}
// 迴圈結束,表示沒有比自己更合適的view
return self;
}
另外提一下,預設UIImageView不能接受觸控事件,因為不允許互動,即userInteractionEnabled = NO,所以如果希望UIImageView可以互動,需要userInteractionEnabled = YES
攔截事件的處理
在遍歷尋找最合適檢視過程中,會呼叫檢視的兩個重要方法:
- hitTest:withEvent:方法
pointInside方法
hit:withEvent:方法底層會呼叫pointInside:withEvent:方法判斷點在不在方法呼叫者的座標系上。
只要事件一傳遞給一個控制元件,這個控制元件就會呼叫他自己的hitTest:withEvent:方法,用於尋找並返回最合適的view(能夠響應事件的那個最合適的view)。
- 如果該方法返回nil,那麼事件便不會往下遍歷,也就是呼叫該方法的控制元件本身和其子控制元件都不是最合適的view,那麼最合適的view就是該控制元件的父控制元件。
- 如果返回的是view,不管該事件是點在哪的,都以該view為最合適檢視。
注 意:不管這個控制元件能不能處理事件,也不管觸控點在不在這個控制元件上,事件都會先傳遞給這個控制元件(包括起父檢視和其子檢視),隨後再呼叫其hitTest:withEvent:方法。
事件的傳遞順序是這樣的:
產生觸控事件->UIApplication事件佇列->[UIWindow hitTest:withEvent:]->返回更合適的view->[子控制元件 hitTest:withEvent:]->返回最合適的view。
事件傳遞給視窗或控制元件的後,就呼叫hitTest:withEvent:方法尋找更合適的view。所以是,先傳遞事件,再根據事件在自己身上找更合適的view。
通過重寫檢視的hitTest:withEvent:,就可以攔截事件的傳遞過程,想讓誰處理事件誰就處理事件。
攔截思路有兩種:
- 想讓誰成為最合適的view就重寫誰自己的父控制元件的hitTest:withEvent:方法返回指定的子控制元件。
- 重寫自己的hitTest:withEvent:方法 return self。
但是,建議在父控制元件的hitTest:withEvent:中返回子控制元件作為最合適的view。why?!還記得嗎,事件傳遞遍歷控制元件的時候,子控制元件檢視都是從後往前遍歷的,也就是後新增的檢視先檢查,如果有多個子檢視,就有可能還沒遍歷到你就先返回真正合適的view。
下面舉個例子:
設有檢視ABC三個檢視
@interface A : UIView
@end
@implementation A
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
NSLog(@"A-touch");
}
@end
@interface B : UIView
@end
@implementation B
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
NSLog(@"B-touch");
}
@end
@interface C : UIView
@end
@implementation C
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
NSLog(@"C-touch");
}
@end
把BC作為A的子檢視
A *a=[[A alloc] initWithFrame:self.view.bounds];
B *b=[[B alloc] initWithFrame:CGRectMake(20, 20, 40, 40)];
C *c=[[C alloc] initWithFrame:CGRectMake(20, 80, 40, 40)];
[self.view addSubview:a];
[a addSubview:b];
[a addSubview:c];
如果我們想讓使用者無論點選A哪個地方,或者是C,都讓B來作為最合適檢視。
可以在A檢視中重寫方法:
@implementation A
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
return self.subviews[0];
}
@end
你會發現無論點的是誰都是列印“B-touch”(B先新增),說明B是成功攔截成為最合適檢視(成為最合適檢視後才能響應touches方法)
那麼下面我們試試不在父檢視A上重寫返回,改為在B檢視重寫返回self
@implementation B
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
return self;
}
@end
如果我們點選A檢視,列印“B-touch”,但如果我們點選C檢視時,列印的卻是“C-touch”,攔截失敗了。
首先,我們點選A檢視的時候,A會遍歷子檢視BC,正常情況下,因為點選不在BC上,A才是最合適檢視,但因為B重寫返回了他自己,所以B成了最合適檢視。但是,如果我們點選在了C上,但A遍歷BC時,是先檢查C的,而剛好點選是在C上面的,C便成了最合適檢視,B就攔截失敗了。
事件響應鏈
使用者點選屏幕後產生的一個觸控事件,經過一系列的傳遞過程後,會找到最合適的檢視控制元件來處理這個事件。
找到最合適的檢視控制元件後,就會呼叫控制元件的touches方法來作具體的事件處理touchesBegan…touchesMoved…touchedEnded…
這些touches方法的預設做法是將事件順著響應者鏈條向上傳遞(也就是touch方法預設不處理事件,只傳遞事件,重寫後才處理),將事件交給上一個響應者進行處理。
響應者鏈條:在iOS程式中無論是最後面的UIWindow還是最前面的某個按鈕,它們的擺放是有前後關係的,一個控制元件可以放到另一個控制元件上面或下面,那麼使用者點選某個控制元件時是觸發上面的控制元件還是下面的控制元件呢,這種先後關係構成一個鏈條就叫“響應者鏈”。也可以說,響應者鏈是由多個響應者物件連線起來的鏈條。
響應者物件:能處理事件的物件,也就是繼承自UIResponder的物件
作用:能很清楚的看見每個響應者之間的聯絡,並且可以讓一個事件多個物件處理。
如何判斷上一個響應者
1> 如果當前這個view是控制器的view,那麼控制器就是上一個響應者
2> 如果當前這個view不是控制器的view,那麼父控制元件就是上一個響應者
響應者鏈的事件傳遞過程:
1>如果當前view是控制器的view,那麼控制器就是上一個響應者,事件就傳遞給控制器;如果當前view不是控制器的view,那麼父檢視就是當前view的上一個響應者,事件就傳遞給它的父檢視
2>在檢視層次結構的最頂級檢視,如果也不能處理收到的事件或訊息,則其將事件或訊息傳遞給window物件進行處理
3>如果window物件也不處理,則其將事件或訊息傳遞給UIApplication物件
4>如果UIApplication也不能處理該事件或訊息,則將其丟棄
可以簡單理解,響應鏈跟事件傳遞時的順序相反。
傳遞先從上往下(UIApplication->window->view),響應是從下往上。
touches預設做法是把事件順著響應者鏈條向上拋,直到找到重寫了該方法的。否則,最後該事件作廢。
touches的預設做法:
@implementation MyView
//只要點選控制元件,就會呼叫touchBegin,如果沒有重寫這個方法,自己處理不了觸控事件
// 上一個響應者可能是父控制元件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
// 預設會把事件傳遞給上一個響應者,上一個響應者是父控制元件,交給父控制元件處理
[super touchesBegan:touches withEvent:event];
// 注意不是呼叫父控制元件的touches方法,而是呼叫父類的touches方法
// super是父類 superview是父控制元件
}
@end
一個事件多個物件響應處理
因為系統預設做法是把事件上拋給父控制元件,所以可以通過重寫自己的touches方法和父控制元件的touches方法來達到一個事件多個物件處理的目的。
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
// 1.自己先處理事件...
NSLog(@"do somthing...");
// 2.再呼叫系統的預設做法,再把事件交給上一個響應者處理
[super touchesBegan:touches withEvent:event];
}
總結
事件在iOS的處理基本可以分為兩部分,先傳遞,後響應。
傳遞先從上往下(UIApplication->window->view),響應是從下往上。
1.當一個事件發生後,事件會從父控制元件傳給子控制元件,也就是說由UIApplication -> UIWindow -> UIView -> initial view,以上就是事件的傳遞,也就是尋找最合適的view的過程。
2.找到最合適view後,傳遞結束,開始進入響應過程。響應順序跟傳遞相反,當響應者沒有重寫touches方法來處理事件,事件就會傳遞給上一級view或者view controller來響應,由上一級繼續檢查,直到有重寫touches方法的。 順序為: initial view->superView->view controller->window->application