事件的產生
- 當有觸摸或者其他事件產生,將事件交由
IOKit.framework
處理。 -
IOKit.framework
將事件封裝成一個IOHIDEvent
對象,并通過mach port
傳遞給SpringBoad
。 -
SpringBoard
會接收這個對象并通過mach port
轉發給當前App
的進程; - 喚醒
runloop
,觸發了source1
回調,其回調函數為__IOHIDEventSystemClientQueueCallback()
。 -
source1
回調觸發source0
回調,將接收到的IOHIDEvent
對象封裝成UIEvent
對象進行處理或分發。
注意:
SpringBoard
其實是一個標準的應用程序,這個應用程序用來管理 iOS
的主屏幕;
source1
是蘋果用來監聽 mach port
傳來的系統事件的,source0
是用來處理用戶事件的。
source1
收到系統事件后,會回調 source0
,所以最終這些事件都是由 source0
處理的。
事件傳遞的流程
- 當用戶點擊屏幕時,會產生一個觸摸事件,系統會將該事件加入到一個由
UIApplication
管理的事件隊列中。 -
UIApplication
會從事件隊列中取出最前面的事件,并將事件分發下去以便處理,通常先發送事件給應用程序的主窗口keyWindow
。 - 主窗口會調用
hitTest:withEvent:
方法在視圖View
層次結構中找到一個最合適的View
來處理觸摸事件。 - 最終,這個觸摸事件交給主窗口的
hitTest:withEvent:
方法返回的視圖對象去處理。
注意:如果父控件不能接受觸摸事件,那么子控件就不可能接收到觸摸事件。
View 不能接收觸摸事件的三種情況:
- 不允許交互:
userInteractionEnabled = NO
; - 隱藏:如果把父控件隱藏,那么子控件也會隱藏,隱藏的控件不能接受事件;
- 透明度:如果設置一個控件的透明度<0.01,會直接影響子控件的透明度,0.0~0.01 為透明。
注意:
默認 UIImageView
不能接受觸摸事件,因為不允許交互,即 userInteractionEnabled = NO
。所以如果希望 UIImageView
可以交互,需要設置 UIImageView
的 userInteractionEnabled = YES
。
如何找到最合適的控件來處理事件?
- 首先判斷主窗口
keyWindow
自己是否能接受觸摸事件。 - 調用當前視圖的
pointInside:withEvent:
方法判斷觸摸點是否在當前視圖內。 - 若
pointInside:withEvent:
方法返回NO
,說明觸摸點不在當前視圖內,則當前視圖的hitTest:withEvent:
返回nil
。 - 若
pointInside:withEvent:
方法返回YES
,說明觸摸點在當前視圖內,則遍歷當前視圖的所有子視圖subviews
,調用子視圖的hitTest:withEvent:
方法重復前面的步驟,子視圖的遍歷順序是從上到下,即從subviews
數組的末尾向前遍歷,直到有子視圖的hitTest:withEvent:
方法返回非空對象或者全部子視圖遍歷完畢。 - 若第一次有子視圖的
hitTest:withEvent:
方法返回非空對象,則當前視圖的hitTest:withEvent:
方法就返回此對象,處理結束。 - 若所有子視圖的
hitTest:withEvent:
方法都返回nil
,則當前視圖的hitTest:withEvent:
方法返回當前視圖自身。
查找第一響應者
hitTest:withEvent:方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
控件通過重寫 hitTest:withEvent:
方法,來判斷點擊區域是否在視圖上,是則返回 YES
,不是則返回 NO
,尋找并返回最合適的 view
(能夠響應事件的那個最合適的 view
)。
應用程序接收到事件后,將事件交給 keyWindow
并轉發給根視圖,根視圖按照視圖層級逐級遍歷子視圖,并且遍歷的過程中不斷判斷視圖范圍,并最終找到第一響應者。
事件傳遞給窗口或控件的后,就遞歸調用 hitTest:withEvent:
方法尋找更合適的 view
。
在 hitTest:withEvent:
方法中,會從上到下遍歷子視圖,并調用 subViews
的 pointInside:withEvent:
方法,通過重寫 pointInside:withEvent:
方法,返回點擊區域是否在視圖上。如果找到子視圖則不斷調用其 hitTest:withEvent:
方法,以此類推。
在 hitTest:withEvent: 方法中返回 nil 的含義:
在 hitTest:withEvent:
方法中返回 nil
的意思是調用當前 hitTest:withEvent:
方法的 view
不是合適的 view
,子控件也不是合適的 view
,如果同級的兄弟控件也沒有合適的 view
,那么最合適的 view
就是父控件。
pointInside:withEvent: 方法
pointInside:withEvent:
方法判斷子控件的點在不在當前 view
上(方法調用者的坐標系上)如果返回 YES
,代表點在方法調用者的坐標系上;返回 NO
代表點不在方法調用者的坐標系上,那么方法調用者也就不能處理事件。
查找第一響應者傳遞過程:
- 如果當前
view
是控制器的view
,那么控制器就是上一個響應者,事件就傳遞給控制器; - 如果當前
view
不是控制器的view
,那么父視圖就是當前view
的上一個響應者,事件就傳遞給它的父視圖。 - 在視圖層次結構的最頂級視圖,如果也不能處理收到的事件或消息,則其將事件或消息傳遞給
window
對象進行處理。 - 如果
window
對象也不處理,則其將事件或消息傳遞給UIApplication
對象。 - 如果
UIApplication
也不能處理該事件或消息,則將其丟棄。
事件攔截
有時候想讓指定視圖來響應事件,不再向其子視圖繼續傳遞事件,可以通過重寫 hitTest:withEvent:
方法。在執行到方法后,直接將該視圖返回,而不再繼續遍歷子視圖,這樣響應者鏈的終端就是當前視圖。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return self;
}
實際開發中可能會遇到一些特殊的交互需求,需要定制視圖對于事件的響應。例如下面 Tabbar
的這種情況,中間的圓形按鈕是底部 Tabbar
上的控件,而 Tabbar
是添加在控制器根視圖中的。默認情況下我們點擊圖中紅色方框中按鈕的區域,會發現按鈕并不會得到響應。
很明顯,圖中紅色方框中按鈕是添加在 Tabbar
上面的,但是圖中紅色方框中按鈕的位置又超出了 Tabbar
的區域,當點擊紅色方框區域后,會發現紅色方框得不到響應。
分析:
- 生成的觸摸事件首先傳到了
UIWindow
,然后UIWindow
將事件傳遞給控制器的根視圖UILayoutContainerView
; -
UILayoutContainerView
判斷自己可以響應觸摸事件,然后將事件傳遞給子視圖Tabbar
; - 子視圖
Tabbar
判斷觸摸點并不在自己的坐標范圍內,因此返回nil
; - 這時
UILayoutContainerView
將事件傳遞其他子視圖UINavigationTransitionView
,UINavigationTransitionView
判斷自己可以響應事件,就將事件時間傳遞給其子視圖UIViewControllerWrapperView
; -
UIViewControllerWrapperView
判斷自己可以響應事件,就將事件傳遞給子視圖UITableViewController
控制器的TableView
; -
TableView
判斷自己可以響應事件,所以UITableViewController
控制器的TableView
就是第一響應者;
整個過程,事件根本沒有傳遞到圖中紅色方框中按鈕;
因此我們需要做的就是修改 Tabbar
的 hitTest:withEvent:
函數里面判斷點擊位置是否在 Tabbar
坐標范圍的的判斷條件,也就是需要重寫 Tabbar
的
pointInside:withEvent:
方法,判斷如果當前觸摸坐標在圖中紅色方框中按鈕上面,就返回 YES
,否則返回 NO
;這樣一來時間就會最終傳遞到圖中紅色方框中按鈕上面,來響應事件。
// TabBar
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
// 將觸摸點坐標轉換到在 circleButton 上的坐標
CGPoint pointTemp = [self convertPoint:point toView:_circleButton];
// 若觸摸點在 cricleButton 上則返回 YES
if ([_circleButton pointInside:pointTemp withEvent:event]) {
return YES;
}
// 否則返回默認的操作
return [super pointInside:point withEvent:event];
}
事件轉發
在開發過程中,經常會遇到子視圖顯示范圍超出父視圖的情況,這時候可以重寫該視圖的 pointInside:withEvent:
方法,將點擊區域擴大到能夠覆蓋所有子視圖。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) return nil;
CGFloat inset = 45.0f - 78.0f;
CGRect touchRect = CGRectInset(self.bounds, inset, inset);
if (CGRectContainsPoint(touchRect, 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;
}