4事件分發機制
iOS中的事件大概分爲三種,分別是Milti-Touch Events, Motion Events和Remote Control Events(events for controlling multimedia)。
4.1hitTest
iOS事件分發機制(一)hit-Testing
http://suenblog.duapp.com/blog/100031/iOS事件分發機制(一)%20hit-Testing
4.1.1事件檢測原理
每當我們點擊了一下iOS設備的屏幕,UIKit就會生成一個事件對象UIEvent,然後會把這個Event分發給當前active的app(官方原文說:Then it places the event object in the active app’s event queue.)
告知當前活動的app有事件之後,UIApplication單例就會從事件隊列中去取最新的事件,然後分發給能夠處理該事件的對象。UIApplication獲取到Event之後,Application就糾結於到底要把這個事件傳遞給誰,這時候就要依靠HitTest來決定了。
iOS中,hit-Testing的作用就是找出這個觸摸點下面的View是什麼,HitTest會檢測這個點擊的點是不是發生在這個View上,如果是的話,就會去遍歷這個View的subviews,直到找到最小的能夠處理事件的view,如果整了一圈沒找到能夠處理的view,則返回自身。來一個簡單的圖說明一下
假設我們現在點擊到了圖中的E,hit-testing將進行如下步驟的檢測(不包含重寫hit-test並且返回非默認View的情況)
1、觸摸點在ViewA內,所以檢查ViewA的Subview B、C;
2、觸摸點不在ViewB內,觸摸點在ViewC內部,所以檢查ViewC的Subview D、E;
3、觸摸點不在ViewD內,觸摸點發生在ViewE內部,並且ViewE沒有subview,所以ViewE屬於ViewA中包含這個點的最小單位,所以ViewE變成了該次觸摸事件的hit-Test View;
4.1.2注意點
1、默認的hit-testing順序是按照UIView中Subviews的逆順序;
2、如果View的同級別Subview中有重疊的部分,則優先檢查頂部的Subview,如果頂部的Subview返回nil,再檢查底部的Subview;
3、Hit-Test也是比較聰明的,檢測過程中有這麼一點,就是說如果點擊沒有發生在某View中,那麼該事件就不可能發生在View的Subview中,所以檢測過程中發現該事件不在ViewB內,也直接就不會檢測在不在ViewF內。也就是說,如果你的Subview設置了clipsToBounds=NO,實際顯示區域可能超出了superView的frame,你點擊超出的部分,是不會處理你的事件的,就是這麼任性!
4.1.3事件檢測實現
Hit-Test的檢查機制如上所示,當確定了Hit-TestView時,如果當前的application沒有忽略觸摸事件(UIApplication:isIgnoringInteractionEvents),則application就會去分發事件(sendEvent:->keywindow:sendEvent:)。
UIView中提供兩個方法用來確定hit-testing View,如下所示
-(UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event;
// recursively calls-pointInside:withEvent:. point is in the receiver's coordinate system
-(BOOL)pointInside:(CGPoint)pointwithEvent:(UIEvent *)event;
// default returns YES if point is inbounds
當一個View收到hitTest消息時,會調用自己的pointInside:withEvent:方法,如果pointInside返回YES,則表明觸摸事件發生在我自己內部,則會遍歷自己的所有Subview去尋找最小單位(沒有任何子view)的UIView,如果當前View.userInteractionEnabled = NO, enabled=NO(UIControl),或者alpha<=0.01, hidden等情況的時候,hitTest就不會調用自己的pointInside了,直接返回nil,然後系統就回去遍歷兄弟節點。簡而言之,可以寫成這樣
[st_hitTest:withEvent:]
- (UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{
if(self.alpha<=0.01||!self.userInteractionEnabled||self.hidden) {
returnnil;
}
BOOLinside=[selfpointInside:pointwithEvent:event];
UIView*hitView=nil;
if(inside) {
NSEnumerator*enumerator=[self.subviewsreverseObjectEnumerator];
for(UIView*subviewinenumerator) {
hitView=[subviewhitTest:pointwithEvent:event];
if(hitView) {
break;
}
}
if(!hitView) {
hitView=self;
}
returnhitView;
}else{
returnnil;
}
}
hit-Test是事件分發的第一步,就算你的app忽略了事件,也會發生hit-Test。確定了hit-TestView之後,纔會開始進行下一步的事件分發。
我們可以利用hit-Test做一些事情,比如我們點擊了ViewA,我們想讓ViewB響應,這個時候,我們只需要重寫View's
hitTest方法,返回ViewB就可以了,雖然可能用不到,但是偶爾還是會用到的。大概代碼如下:
[STPView]
@interfaceSTPView: UIView
@end
@implementationSTPView
- (instancetype)initWithFrame:(CGRect)frame{
self=[superinitWithFrame:frame];
if(self) {
UIButton*button=[UIButtonbuttonWithType:UIButtonTypeCustom];
button.frame=CGRectMake(0,0,CGRectGetWidth(frame),CGRectGetHeight(frame)/2);
button.tag=10001;
button.backgroundColor=[UIColorgrayColor];
[buttonsetTitle:@"Button1"forState:UIControlStateNormal];
[selfaddSubview:button];
[buttonaddTarget:selfaction:@selector(_buttonActionFired:)forControlEvents:UIControlEventTouchDown];
UIButton*button2=[UIButtonbuttonWithType:UIButtonTypeCustom];
button2.frame=CGRectMake(0,CGRectGetHeight(frame)/2,CGRectGetWidth(frame),CGRectGetHeight(frame)/2);
button2.tag=10002;
button2.backgroundColor=[UIColordarkGrayColor];
[button2setTitle:@"Button2"forState:UIControlStateNormal];
[selfaddSubview:button2];
[button2addTarget:selfaction:@selector(_buttonActionFired:)forControlEvents:UIControlEventTouchDown];
}
returnself;
}
- (void)_buttonActionFired:(UIButton*)button{
NSLog(@"=====Button Titled %@ ActionFired
", [buttontitleForState:UIControlStateNormal]);
}
- (UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{
UIView*hitView=[superhitTest:pointwithEvent:event];
if(hitView==[selfviewWithTag:10001]) {
return[selfviewWithTag:10002];
}
returnhitView;
}
@end
4.1.4利用catalog實現hitTest
來自STKit,這個category的目的就是方便的編寫hitTest方法,由於hitTest方法是override,而不是delegate,所以使用默認的實現方式就比較麻煩。Category如下
[UIView+HitTest.h]
/**
* @abstract hitTestBlock
*
* @param其餘參數參考UIView hitTest:withEvent:
* @param returnSuper是否返回Super的值。
*如果*returnSuper=YES,則代表會返回super hitTest:withEvent:,否則則按照block的返回值(即使是nil)
*
* @discussion切記,千萬不要在這個block中調用self hitTest:withPoint,否則則會造成遞歸調用。
*這個方法就是hitTest:withEvent的一個代替。
*/
typedefUIView*(^STHitTestViewBlock)(CGPointpoint, UIEvent*event,BOOL*returnSuper);
typedefBOOL(^STPointInsideBlock)(CGPointpoint, UIEvent*event,BOOL*returnSuper);
@interfaceUIView(STHitTest)
/// althought this is strong ,but i deal it with copy
@property(nonatomic,strong)STHitTestViewBlockhitTestBlock;
@property(nonatomic,strong)STPointInsideBlockpointInsideBlock;
@end
[UIView+HitTest.m]
@implementationUIView(STHitTest)
conststaticNSString*STHitTestViewBlockKey=@"STHitTestViewBlockKey";
conststaticNSString*STPointInsideBlockKey=@"STPointInsideBlockKey";
+ (void)load{
method_exchangeImplementations(class_getInstanceMethod(self,@selector(hitTest:withEvent:)),
class_getInstanceMethod(self,@selector(st_hitTest:withEvent:)));
method_exchangeImplementations(class_getInstanceMethod(self,@selector(pointInside:withEvent:)),
class_getInstanceMethod(self,@selector(st_pointInside:withEvent:)));
}
- (UIView*)st_hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{
NSMutableString*spaces=[NSMutableStringstringWithCapacity:20];
UIView*superView=self.superview;
while(superView) {
[spacesappendString:@"----"];
superView=superView.superview;
}
NSLog(@"%@%@:[hitTest:withEvent:]",spaces,NSStringFromClass(self.class));
UIView*deliveredView=nil;
//如果有hitTestBlock的實現,則調用block
if(self.hitTestBlock) {
BOOLreturnSuper=NO;
deliveredView=self.hitTestBlock(point,event,&returnSuper);
if(returnSuper) {
deliveredView=[selfst_hitTest:pointwithEvent:event];
}
}else{
deliveredView=[selfst_hitTest:pointwithEvent:event];
}
//NSLog(@"%@%@:[hitTest:withEvent:] Result:%@", spaces, NSStringFromClass(self.class), NSStringFromClass(deliveredView.class));
returndeliveredView;
}
- (BOOL)st_pointInside:(CGPoint)pointwithEvent:(UIEvent*)event{
NSMutableString*spaces=[NSMutableStringstringWithCapacity:20];
UIView*superView=self.superview;
while(superView) {
[spacesappendString:@"----"];
superView=superView.superview;
}
NSLog(@"%@%@:[pointInside:withEvent:]",spaces,NSStringFromClass(self.class));
BOOLpointInside=NO;
if(self.pointInsideBlock) {
BOOLreturnSuper=NO;
pointInside=self.pointInsideBlock(point,event,&returnSuper);
if(returnSuper) {
pointInside=[selfst_pointInside:pointwithEvent:event];
}
}else{
pointInside=[selfst_pointInside:pointwithEvent:event];
}
returnpointInside;
}
- (void)setHitTestBlock:(STHitTestViewBlock)hitTestBlock{
objc_setAssociatedObject(self, (__bridgeconstvoid*)(STHitTestViewBlockKey),
hitTestBlock,OBJC_ASSOCIATION_COPY);
}
- (STHitTestViewBlock)hitTestBlock{
returnobjc_getAssociatedObject(self, (__bridgeconstvoid*)(STHitTestViewBlockKey));
}
- (void)setPointInsideBlock:(STPointInsideBlock)pointInsideBlock{
objc_setAssociatedObject(self, (__bridgeconstvoid*)(STPointInsideBlockKey),
pointInsideBlock,OBJC_ASSOCIATION_COPY);
}
- (STPointInsideBlock)pointInsideBlock{
returnobjc_getAssociatedObject(self, (__bridgeconstvoid*)(STPointInsideBlockKey));
}
@end
代碼很簡單,就是利用iOS的runtime能力,在hitTest執行之前,插入了一個方法。如果有看不懂的,可以參考我以前的博客iOS面向切面編程
4.2Responder Chain
參考文檔:
iOS事件分發機制(二)The ResponderChain
http://suenblog.duapp.com/blog/100032/iOS事件分發機制(二)The%20Responder%20Chain
4.2.1事件傳遞原理
響應鏈簡單來說,就是一系列的相互關聯的對象,從firstResponder開始,到application對象結束,如果firstResponder無法響應事件,則交給nextResponder來處理,直到結束爲止。iOS中很多類型的事件分發,都依賴於響應鏈;在響應鏈中,所有對象的基類都是UIResponder,也就是說所有能響應事件的類都是UIResponder的子類,UIApplication/ UIView/ UIViewController都是UIResponder的子類,這說明所有的Views,絕大部分Controllers(不用來管理View的Controller除外)都可以響應事件。
PS:CALayer不是UIResponder的子類,這說明CALayer無法響應事件,這也是UIView和CALayer的重要區別之一。
如果找到的hitTestView無法處理這個事件,事件就通過響應鏈往上傳遞(hitTestView算是最早的Responder),直到找到一個可以處理的Responder爲止。
舉個例子,如果觸摸通過hitTest確定的是一個View,而這個View沒有處理事件,則事件會發送給nextResponder去處理,通常是superView,有關nextResponder的事件傳遞過程,官方給出了一張很形象的圖,如下所示:
PS:View處理事件的方式有手勢或者重寫touchesEvent方法或者利用系統封裝好的組件(UIControls)。
圖中所表示的正是nextResponder的查找過程,兩種方式分別對應兩種app的架構,左邊的那種app架構比較簡單,只有一個VC,右邊的稍微複雜一些,但是尋找路線的原則是一樣的,先解釋一下,UIResponder本身是不會去存儲或者設置nextResponder的,所謂的nextResponder都是子類去實現的(這裏說的是UIView,UIViewController,UIApplication),關於nextResponder的值總結如下:
1、UIView的nextResponder是直接管理它的UIViewController(也就是VC.view.nextResponder=VC),如果當前View不是ViewController直接管理的View,則nextResponder是它的superView(view.nextResponder= view.superView)
2、UIViewController的nextResponder是它直接管理的View的superView (VC. nextResponder = VC.view.superView)
3、UIWindow的nextResponder是UIApplication
4、UIApplication的nextResponder是UIApplicationDelegate(官方文檔說是nil)
我寫了一段代碼,打印當前UIResponder的所有nextResponder,大家可以拿去試一下,代碼很簡單,如下:
[STLogResponderChain]
voidSTLogResponderChain(UIResponder*responder) {
NSLog(@"------------------The
Responder Chain------------------");
NSMutableString*spaces=[NSMutableStringstringWithCapacity:4];
while(responder) {
NSLog(@"%@%@",spaces,responder.class);
responder=responder.nextResponder;
[spacesappendString:@"----"];
}
}
然後我測試了一下,打印的日誌如下圖所示:
[Log]
UIButton
----STPView
--------UIView
------------STPFeedViewController
----------------UIView
--------------------UIView
------------------------_STWrapperViewController
----------------------------UIView
--------------------------------UIView
------------------------------------STNavigationController
----------------------------------------STPWindow
--------------------------------------------UIApplication
------------------------------------------------STPAppDelegate
這樣比較清晰,大家也會直觀的看到nextResponder的查找過程。
4.2.2使用示例
接下來我們說正事了,假定我們現在有一個View是hitTestView,命名爲STImageView,現在我們想讓這個image處理一些事情,比如所有的圖片點下之後加一個灰色的效果,我們就把事件分發給它。
在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;
如果我們想讓我們當前的Responder處理事件,我們則需要重寫如下的幾個方法。我們的需求是手指按下圖片的時候加一個灰色的效果,鬆開的時候灰色消失。關於灰色的實現,我們暫定用一個View貼在ImageView上named
maskView,然後用hidden來控制是否顯示(上一篇文章有說過,所有hidden的View默認不接受任何事件)。
我們需要在touchesBegan方法裏面self.maskView.hidden = NO;然後在touchesEnded/ Cancelled裏面self.maskView.hidden = YES;就可以實現我們的效果了,原理很簡單,我們的hitTestView在事件分發的時候去處理事件,僅此而已。這裏注意一下:UIImageView的默認是不接受點擊事件的,如果想要實現如上所示效果,需要設置userInteractionEnabled=YES;
說到這裏,就有人產生了疑問,如果這麼實現的話,那如果本身UIImageView還想讓下面的View處理事件該怎麼辦?會不會把所有的事件攔截下來?這裏就說到了另一個問題,UIResponder在知道需要處理事件的時候,還是有決定權的,比如我可以決定讓整個響應鏈繼續走下去,或者直接中斷掉整個響應鏈。如果中斷了響應鏈,那麼所有在鏈上的nextResponder都不會得知有事件發生,iOS也提供了這個方法,其實很簡單:
我們在重寫TouchesEvents的時候,如果不想讓響應鏈繼續傳遞,就不調用super對應的實現就可以了,相反,有些時候你只需要做一個小改變,如上所示,但是你不想中斷響應鏈,你就需要調用父類對應的實現。
這裏有一點需要注意,一般來說,我們如果想要自己處理一些事件,我們需要重寫如上所示的方法,如果我們想自己處理,就不需要調用super。調用super的目的就是爲了把事件傳遞給nextResponder,並且如果我們在touchesBegan中沒有調用super,則super不會響應其他的回掉(touchesMoved/touchesEnded),但是我們需要重寫所有如上所示的方法來確保我們的一切正常。touchesBegan和touchesEnded/touchesCancelled一定是成對出現的,這點大家可以放心。
有關觸摸事件在響應鏈上的分發,就差不多這麼多東西,最重要的是大家可以看那幾個touches方法,多做實驗,就可以瞭解的更加深入。
4.2.3其他要點
這裏有一些補充,響應鏈能夠處理很多東西,不僅僅是觸摸事件。一般來說,如果我們需要一個對象去處理一個非觸摸事件(搖一搖,RemoteControlEvents,調用系統的複製、粘貼框等),我們要確保該對象是UIResponder子類,如果我們要接收到事件的話,我們需要做兩件事情
1、重寫canBecomeFirstResponder,並且返回YES
2、在需要的時候像該對象發送becomeFirstResponder消息。
我們有時候會遇到一些問題,比如我們重寫了motionEvents,但是我們不能收到搖一搖的回調,或者我們的UIMenuController老是不彈出,我們就需要檢查一下,我們是否滿足瞭如上所示的條件,而且要確保becomeFirstResponder的發送時機正確。
當然,這個補充對於觸摸事件無效,觸摸事件的第一響應者是根據hitTest確定而來的,有點繞,需要仔細捋捋。
需要注意的是:
如果你自己想自定義一個非TouchEvent的事件,當需要繼續傳遞事件的話,切記不要在實現內直接顯示的調用nextResponder的對應方法, 而是直接調用super對應的方法來讓這個事件繼續分發到響應鏈。
到目前爲止,事件的分發還沒有結束,之後會有一篇文章介紹一個很重要的角色,手勢。
最後,附上官方的文檔
Guide for iOS