iOS中的事件大概分為三種,分別是 Milti-Touch Events, Motion Events 和Remote Control Events(events for controlling multimedia)。
本文將主要針對TouchEvents的分發,做一個詳細的介紹。先丟擲一個問題,文章的後續部分會對問題進行解答:iOS7原生的自帶NavigationController可以實現從最左側拖動PopViewController(大約13pt),不管當前可見的ViewController有沒有其他的滑動手勢或者事件,這是為什麼?如何實現。
我們已經處理過太多觸控事件了,比如按鈕的點選事件,一些View的手勢等等。那到底我們點一下螢幕,當前的View是如何知道他被點選了呢,這個就要通過HitTest來確定了
每當我們點選了一下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-TestView
PS.
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,你點選超出的部分,是不會處理你的事件的,就是這麼任性!
Hit-Test的檢查機制如上所示,當確定了Hit-TestView時,如果當前的application沒有忽略觸控事件 (UIApplication:isIgnoringInteractionEvents),則application就會去分發事件(sendEvent:->keywindow:sendEvent:)
UIView中提供兩個方法用來確定hit-testing View,如下所示 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds
當一個View收到hitTest訊息時,會呼叫自己的pointInside:withEvent:方法,如果pointInside返回YES,則表明觸控事件發生在我自己內部,則會遍歷自己的所有Subview去尋找最小單位(沒有任何子view)的UIView,如果當前View.userInteractionEnabled = NO,enabled=NO(UIControl),或者alpha<=0.01, hidden等情況的時候,hitTest就不會呼叫自己的pointInside了,直接返回nil,然後系統就回去遍歷兄弟節點。簡而言之,可以寫成這樣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) { return nil; } BOOL inside = [self pointInside:point withEvent:event]; UIView *hitView = nil; if (inside) { NSEnumerator *enumerator = [self.subviews reverseObjectEnumerator]; for (UIView *subview in enumerator) { hitView = [subview hitTest:point withEvent:event]; if (hitView) { break; } } if (!hitView) { hitView = self; } return hitView; } else { return nil; } } |
hit-Test 是事件分發的第一步,就算你的app忽略了事件,也會發生hit-Test。確定了hit-TestView之後,才會開始進行下一步的事件分發。
我們可以利用hit-Test做一些事情,比如我們點選了ViewA,我們想讓ViewB響應,這個時候,我們只需要重寫View’s hitTest方法,返回ViewB就可以了,雖然可能用不到,但是偶爾還是會用到的。大概程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
@interface STPView : UIView @end @implementation STPView - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; button.frame = CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2); button.tag = 10001; button.backgroundColor = [UIColor grayColor]; [button setTitle:@"Button1" forState:UIControlStateNormal]; [self addSubview:button]; [button addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown]; UIButton *button2 = [UIButton buttonWithType:UIButtonTypeCustom]; button2.frame = CGRectMake(0, CGRectGetHeight(frame) / 2, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2); button2.tag = 10002; button2.backgroundColor = [UIColor darkGrayColor]; [button2 setTitle:@"Button2" forState:UIControlStateNormal]; [self addSubview:button2]; [button2 addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown]; } return self; } - (void)_buttonActionFired:(UIButton *)button { NSLog(@"=====Button Titled %@ ActionFired ", [button titleForState:UIControlStateNormal]); } - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { UIView *hitView = [super hitTest:point withEvent:event]; if (hitView == [self viewWithTag:10001]) { return [self viewWithTag:10002]; } return hitView; } @end |
大家可以試一試,上述程式碼在點選上面的按鈕的時候,實際會觸發下面按鈕的事件,不是經常用到,但是也算是漲姿勢了,這裡給大家提供一個Category,來自STKit,這個category的目的就是方便的編寫hitTest方法,由於hitTest方法是override,而不是delegate,所以使用預設的實現方式就比較麻煩。Category如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/** * @abstract hitTestBlock * * @param 其餘引數 參考UIView hitTest:withEvent: * @param returnSuper 是否返回Super的值。如果*returnSuper=YES,則代表會返回 super hitTest:withEvent:, 否則則按照block的返回值(即使是nil) * * @discussion 切記,千萬不要在這個block中呼叫self hitTest:withPoint,否則則會造成遞迴呼叫。這個方法就是hitTest:withEvent的一個代替。 */ typedef UIView * (^STHitTestViewBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper); typedef BOOL (^STPointInsideBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper); @interface UIView (STHitTest) /// althought this is strong ,but i deal it with copy @property(nonatomic, strong) STHitTestViewBlock hitTestBlock; @property(nonatomic, strong) STPointInsideBlock pointInsideBlock; @end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
@implementation UIView (STHitTest) const static NSString *STHitTestViewBlockKey = @"STHitTestViewBlockKey"; const static NSString *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)point withEvent:(UIEvent *)event { NSMutableString *spaces = [NSMutableString stringWithCapacity:20]; UIView *superView = self.superview; while (superView) { [spaces appendString:@"----"]; superView = superView.superview; } NSLog(@"%@%@:[hitTest:withEvent:]", spaces, NSStringFromClass(self.class)); UIView *deliveredView = nil; // 如果有hitTestBlock的實現,則呼叫block if (self.hitTestBlock) { BOOL returnSuper = NO; deliveredView = self.hitTestBlock(point, event, &returnSuper); if (returnSuper) { deliveredView = [self st_hitTest:point withEvent:event]; } } else { deliveredView = [self st_hitTest:point withEvent:event]; } // NSLog(@"%@%@:[hitTest:withEvent:] Result:%@", spaces, NSStringFromClass(self.class), NSStringFromClass(deliveredView.class)); return deliveredView; } - (BOOL)st_pointInside:(CGPoint)point withEvent:(UIEvent *)event { NSMutableString *spaces = [NSMutableString stringWithCapacity:20]; UIView *superView = self.superview; while (superView) { [spaces appendString:@"----"]; superView = superView.superview; } NSLog(@"%@%@:[pointInside:withEvent:]", spaces, NSStringFromClass(self.class)); BOOL pointInside = NO; if (self.pointInsideBlock) { BOOL returnSuper = NO; pointInside = self.pointInsideBlock(point, event, &returnSuper); if (returnSuper) { pointInside = [self st_pointInside:point withEvent:event]; } } else { pointInside = [self st_pointInside:point withEvent:event]; } return pointInside; } - (void)setHitTestBlock:(STHitTestViewBlock)hitTestBlock { objc_setAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey), hitTestBlock, OBJC_ASSOCIATION_COPY); } - (STHitTestViewBlock)hitTestBlock { return objc_getAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey)); } - (void)setPointInsideBlock:(STPointInsideBlock)pointInsideBlock { objc_setAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey), pointInsideBlock, OBJC_ASSOCIATION_COPY); } - (STPointInsideBlock)pointInsideBlock { return objc_getAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey)); } @end |
程式碼很簡單,就是利用iOS的runtime能力,在hitTest執行之前,插入了一個方法。如果有看不懂的,可以參考我以前的部落格 iOS面向切面程式設計
現在回到我們開始提出的題目,其實題目很簡單,就是簡單的可以把題目轉換為
如果我們觸控點的座標 point.x < 13, 我們就讓hit-Test 返回NavigationController.view, 把所有的事件入口交給他,否則就返回super,該怎麼處理怎麼處理
這樣就能滿足我們的條件,即使當前的VC上面有ScrollView,但是由於點選特定區域的時候,ScrollView根本得不到事件,所以系統會專心處理NavigationController的拖拽手勢,而不是ScrollView的事件,當沒有點選特定區域的時候,NavigationController的手勢不會觸發,系統會專心處理ScrollView的事件,互不影響,大家可以嘗試實現,程式碼量不多。
雖然iOS8新增了UIScreenEdgePanGestureRecognizer 手勢,但是單純的用這個手勢無法解決當前VC上面有ScrollView的問題,有關手勢方面的事件分發,之後的文章會對此進行說明,這裡就不多說了。
當我們確定了HitTestView之後,我們的事件分發就正式開始了,如果hitTestView可以直接處理的,就處理,不能處理的,則交給 The Responder Chain/ GestureRecognizer。後續文章會對分發進行進一步說明。
附上一些測試查詢hitTestView過程中列印的日誌,可以觀察一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
STPWindow:[hitTest:withEvent:] ----UIView:[hitTest:withEvent:] --------STPView:[hitTest:withEvent:] --------UICollectionView:[hitTest:withEvent:] ------------UIImageView:[hitTest:withEvent:] ------------UIImageView:[hitTest:withEvent:] ------------STDefaultRefreshControl:[hitTest:withEvent:] ------------STPFeedCell:[hitTest:withEvent:] ------------STPFeedCell:[hitTest:withEvent:] ----------------UIView:[hitTest:withEvent:] --------------------UIImageView:[hitTest:withEvent:] ------------------------UIImageView:[hitTest:withEvent:] ------------------------UIView:[hitTest:withEvent:] ------------------------STImageView:[hitTest:withEvent:] |
其中—-表示View的層次結構