iOS 事件處理,看我就夠了
UIResponder
UIResponder是 iOS 中用於處理用戶事件的 API,可以處理觸摸事件、按壓事件(3D touch)、遠程控制事件、硬件運動事件。 可以通過touchesBegan、pressesBegan、motionBegan、remoteControlReceivedWithEvent等方法,獲取到對應的回調消息。UIResponder不只用來接收事件,還可以處理和傳遞對應的事件,如果當前響應者不能處理,則轉發給其他合適的響應者處理。
應用程序通過響應者來接收和處理事件,響應者可以是繼承自UIResponder的任何子類,例如UIView、UIViewController、UIApplication等。當事件來到時,系統會將事件傳遞給合適的響應者,並且將其成爲第一響應者。
第一響應者未處理的事件,將會在響應者鏈中進行傳遞,傳遞規則由UIResponder的nextResponder決定,可以通過重寫該屬性來決定傳遞規則。當一個事件到來時,第一響應者沒有接收消息,則順着響應者鏈向後傳遞。
查找第一響應者
基礎 API
查找第一響應者時,有兩個非常關鍵的API,查找第一響應者就是通過不斷調用子視圖的這兩個API完成的。
調用方法,獲取到被點擊的視圖,也就是第一響應者。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
hitTest:withEvent:方法內部會通過調用這個方法,來判斷點擊區域是否在視圖上,是則返回YES,不是則返回NO。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
查找第一響應者
應用程序接收到事件後,將事件交給keyWindow並轉發給根視圖,根視圖按照視圖層級逐級遍歷子視圖,並且遍歷的過程中不斷判斷視圖範圍,並最終找到第一響應者。
從keyWindow開始,向前逐級遍歷子視圖,不斷調用UIView的hitTest:withEvent:方法,通過該方法查找在點擊區域中的視圖後,並繼續調用返回視圖的子視圖的hitTest:withEvent:方法,以此類推。如果子視圖不在點擊區域或沒有子視圖,則當前視圖就是第一響應者。
在hitTest:withEvent:方法中,會從上到下遍歷子視圖,並調用subViews的pointInside:withEvent:方法,來找到點擊區域內且最上面的子視圖。如果找到子視圖則調用其hitTest:withEvent:方法,並繼續執行這個流程,以此類推。如果子視圖不在點擊區域內,則忽略這個視圖及其子視圖,繼續遍歷其他視圖。
可以通過重寫對應的方法,控制這個遍歷過程。通過重寫pointInside:withEvent:方法,來做自己的判斷並返回YES或NO,返回點擊區域是否在視圖上。通過重寫hitTest:withEvent:方法,返回被點擊的視圖。
此方法在遍歷視圖時,忽略以下三種情況的視圖,如果視圖具有以下特徵則忽略。但是視圖的背景顏色是clearColor,並不在忽略範圍內。
-
視圖的
hidden等於 YES。 -
視圖的
alpha小於等於 0.01。 -
視圖的
userInteractionEnabled爲 NO。
如果點擊事件是發生在視圖外,但在其子視圖內部,子視圖也不能接收事件併成爲第一響應者。這是因爲在其父視圖進行hitTest:withEvent:的過程中,就會將其忽略掉。
事件傳遞
傳遞過程
-
UIApplication接收到事件,將事件傳遞給keyWindow。 -
keyWindow遍歷subViews的hitTest:withEvent:方法,找到點擊區域內合適的視圖來處理事件。 -
UIView的子視圖也會遍歷其subViews的hitTest:withEvent:方法,以此類推。 -
直到找到點擊區域內,且處於最上方的視圖,將視圖逐步返回給
UIApplication。 -
在查找第一響應者的過程中,已經形成了一個響應者鏈。
-
應用程序會先調用第一響應者處理事件。
-
如果第一響應者不能處理事件,則調用其
nextResponder方法,一直找響應者鏈中能處理該事件的對象。 -
最後到
UIApplication後仍然沒有能處理該事件的對象,則該事件被廢棄。
模擬代碼
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.alpha <= 0.01 || self.userInteractionEnabled == NO || self.hidden) {
return nil;
}
BOOL inside = [self pointInside:point withEvent:event];
if (inside) {
NSArray *subViews = self.subviews;
// 對子視圖從上向下找
for (NSInteger i = subViews.count - 1; i >= 0; i--) {
UIView *subView = subViews[i];
CGPoint insidePoint = [self convertPoint:point toView:subView];
UIView *hitView = [subView hitTest:insidePoint withEvent:event];
if (hitView) {
return hitView;
}
}
return self;
}
return nil;
}
示例
如上圖所示,響應者鏈如下:
-
如果點擊
UITextField後其會成爲第一響應者。 -
如果
textField未處理事件,則會將事件傳遞給下一級響應者鏈,也就是其父視圖。 -
父視圖未處理事件則繼續向下傳遞,也就是
UIViewController的View。 -
如果控制器的
View未處理事件,則會交給控制器處理。 -
控制器未處理則會交給
UIWindow。 -
然後會交給
UIApplication。 -
最後交給
UIApplicationDelegate,如果其未處理則丟棄事件。
事件通過UITouch進行傳遞,在事件到來時,第一響應者會分配對應的UITouch,UITouch會一直跟隨着第一響應者,並且根據當前事件的變化UITouch也會變化,當事件結束後則UITouch被釋放。
UIViewController沒有hitTest:withEvent:方法,所以控制器不參與查找響應視圖的過程。但是控制器在響應者鏈中,如果控制器的View不處理事件,會交給控制器來處理。控制器不處理的話,再交給View的下一級響應者處理。
注意
-
在執行
hitTest:withEvent:方法時,如果該視圖是hidden等於 NO 的那三種被忽略的情況,則改視圖返回nil。 -
如果當前視圖在響應者鏈中,但其沒有處理事件,則不考慮其兄弟視圖,即使其兄弟視圖和其都在點擊範圍內。
-
UIImageView的userInteractionEnabled默認爲 NO,如果想要UIImageView響應交互事件,將屬性設置爲 YES 即可響應事件。
事件控制
事件攔截
有時候想讓指定視圖來響應事件,不再向其子視圖繼續傳遞事件,可以通過重寫hitTest:withEvent:方法。在執行到方法後,直接將該視圖返回,而不再繼續遍歷子視圖,這樣響應者鏈的終端就是當前視圖。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return self;
}
事件轉發
在開發過程中,經常會遇到子視圖顯示範圍超出父視圖的情況,這時候可以重寫該視圖的pointInside:withEvent:方法,將點擊區域擴大到能夠覆蓋所有子視圖。
假設有上面的視圖結構,SuperView的Subview超出了其視圖範圍,如果點擊Subview在父視圖外面的部分,則不能響應事件。所以通過重寫pointInside:withEvent:方法,將響應區域擴大爲虛線區域,包含SuperView的所有子視圖,即可讓子視圖響應事件。
事件逐級傳遞
如果想讓響應者鏈中,每一級UIResponder都可以響應事件,可以在每級UIResponder中都實現touches並調用super方法,即可實現響應者鏈事件逐級傳遞。
只不過這並不包含UIControl子類以及UIGestureRecognizer的子類,這兩類會直接打斷響應者鏈。
Gesture Recognizer
如果有事件到來時,視圖有附加的手勢識別器,則手勢識別器優先處理事件。如果手勢識別器沒有處理事件,則將事件交給視圖處理,視圖如果未處理則順着響應者鏈繼續向後傳遞。
當響應者鏈和手勢同時出現時,也就是既實現了touches方法又添加了手勢,會發現touches方法有時會失效,這是因爲手勢的執行優先級是高於響應者鏈的。
事件到來後先會執行hitTest和pointInside操作,通過這兩個方法找到第一響應者,這個在上面已經詳細講過了。當找到第一響應者並將其返回給UIApplication後,UIApplication會向第一響應者派發事件,並且遍歷整個響應者鏈。如果響應者鏈中能夠處理當前事件的手勢,則將事件交給手勢處理,並調用touches的cancelled方法將響應者鏈取消。
在UIApplication向第一響應者派發事件,並且遍歷響應者鏈查找手勢時,會開始執行響應者鏈中的touches系列方法。會先執行touchesBegan和touchesMoved方法,如果響應者鏈能夠繼續響應事件,則執行touchesEnded方法表示事件完成,如果將事件交給手勢處理則調用touchesCancelled方法將響應者鏈打斷。
根據蘋果的官方文檔,手勢不參與響應者鏈傳遞事件,但是也通過hitTest的方式查找響應的視圖,手勢和響應者鏈一樣都需要通過hitTest方法來確定響應者鏈的。在UIApplication向響應者鏈派發消息時,只要響應者鏈中存在能夠處理事件的手勢,則手勢響應事件,如果手勢不在響應者鏈中則不能處理事件。
Apple UIGestureRecognizer Documentation
UIControl
根據上面的手勢和響應者鏈的處理規則,我們會發現UIButton或者UISlider等控件,並不符合這個處理規則。UIButton可以在其父視圖已經添加tapGestureRecognizer的情況下,依然正常響應事件,並且tap手勢不響應。
以UIButton爲例,UIButton也是通過hitTest的方式查找第一響應者的。區別在於,如果UIButton是第一響應者,則直接由UIApplication派發事件,不通過Responder Chain派發。如果其不能處理事件,則交給手勢處理或響應者鏈傳遞。
不只UIButton是直接由UIApplication派發事件的,所有繼承自UIControl的類,都是由UIApplication直接派發事件的。
Apple UIControl Documentation
事件傳遞優先級
測試
爲了有依據的推斷響應事件的實現和傳遞機制,我們做以下測試。
示例 1
假設RootView、SuperView、Button都實現touches方法,並且Button添加buttonAction:的action,點擊button後的調用如下。
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
Button -> touchesBegan:withEvent:
Button -> touchesEnded:withEvent:
Button -> buttonAction:
示例 2
還是上面的視圖結構,我們給RootView加上UITapGestureRecognizer手勢,並且通過tapAction:方法接收回調,點擊上面的SuperView後,方法調用如下。
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
RootView -> gestureRecognizer:shouldReceivePress:
RootView -> gestureRecognizer:shouldBeRequiredToFailByGestureRecognizer:
SuperView -> touchesBegan:withEvent:
RootView -> gestureRecognizerShouldBegin:
RootView -> tapAction:
SuperView -> touchesCancelled:
示例 3
上面的視圖中Subview1、Subview2、Subview3是同級視圖,都是SuperView的子視圖。我們給Subview1加上UITapGestureRecognizer手勢,並且通過subView1Action:方法接收回調,點擊上面的Subview3後,方法調用如下。
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Subview3 -> hitTest:withEvent:
Subview3 -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Subview3 -> touchesBegan:withEvent:
Subview3 -> touchesEnded:withEvent:
通過上面的例子來看,雖然Subview1在Subview3的下面,並且添加了手勢,點擊區域是在Subview1和Subview3兩個視圖上的。但是由於經過hitTest和pointInside之後,響應者鏈中並沒有Subview1,所以Subview1的手勢並沒有被響應。
分析
根據我們上面的測試,推斷 iOS 響應事件的優先級,以及整體的響應邏輯。
當事件到來時,會通過hitTest和pointInside兩個方法,從Window開始向上面的視圖查找,找到第一響應者的視圖。找到第一響應者後,系統會判斷其是繼承自UIControl還是UIResponder,如果是繼承自UIControl,則直接通過UIApplication直接向其派發消息,並且不再向響應者鏈派發消息。
如果是繼承自UIResponder的類,則調用第一響應者的touchesBegin,並且不會立即執行touchesEnded,而是調用之後順着響應者鏈向後查找。如果在查找過程中,發現響應者鏈中有的視圖添加了手勢,則進入手勢的代理方法中,如果代理方法返回可以響應這個事件,則將第一響應者的事件取消,並調用其touchesCanceled方法,然後由手勢來響應事件。
如果手勢不能處理事件,則交給第一響應者來處理。如果第一響應者也不能響應事件,則順着響應者鏈繼續向後查找,直到找到能夠處理事件的UIResponder對象。如果找到UIApplication還沒有對象響應事件的話,則將這次事件丟棄。
接收事件深度剖析
在UIApplication接收到響應事件之前,還有更復雜的系統級的處理,處理流程大致如下。
-
系統通過
IOKit.framework來處理硬件操作,其中屏幕處理也通過IOKit完成 (IOKit可能是註冊監聽了屏幕輸出的端口)當用戶操作屏幕,
IOKit收到屏幕操作,會將這次操作封裝爲IOHIDEvent對象。通過mach port(IPC 進程間通信) 將事件轉發給SpringBoard來處理。 -
SpringBoard是 iOS 系統的桌面程序。SpringBoard收到mach port發過來的事件,喚醒main runloop來處理。
main runloop將事件交給source1處理,source1會調用__IOHIDEventSystemClientQueueCallback()函數。 -
函數內部會判斷,是否有程序在前臺顯示,如果有則通過
mach port將IOHIDEvent事件轉發給這個程序。如果前臺沒有程序在顯示,則表明
SpringBoard的桌面程序在前臺顯示,也就是用戶在桌面進行了操作。
__IOHIDEventSystemClientQueueCallback()函數會將事件交給source0處理,source0會調用__UIApplicationHandleEventQueue()函數,函數內部會做具體的處理操作。 -
例如用戶點擊了某個應用程序的 icon,會將這個程序啓動。
應用程序接收到
SpringBoard傳來的消息,會喚醒main runloop並將這個消息交給source1處理,source1調用__IOHIDEventSystemClientQueueCallback()函數,在函數內部會將事件交給source0處理,並調用source0的__UIApplicationHandleEventQueue()函數。在__UIApplicationHandleEventQueue()函數中,會將傳遞過來的IOHIDEvent轉換爲UIEvent對象。 -
在函數內部,調用
UIApplication的sendEvent:方法,將UIEvent傳遞給第一響應者或UIControl對象處理,在UIEvent內部包含若干個UITouch對象。
Tips
source1是runloop用來處理mach port傳來的系統事件的,source0是用來處理用戶事件的。source1收到系統事件後,都會調用source0的函數,所以最終這些事件都是由source0處理的。
小技巧
在開發中,有時會有找到當前View對應的控制器的需求,這時候就可以利用我們上面所學,根據響應者鏈來找到最近的控制器。
在UIResponder中提供了nextResponder方法,通過這個方法可以找到當前響應環節的上一級響應對象。可以從當前UIView開始不斷調用nextResponder,查找上一級響應者鏈的對象,就可以找到離自己最近的UIViewController。
示例代碼:
- (UIViewController *)parentController {
UIResponder *responder = [self nextResponder];
while (responder) {
if ([responder isKindOfClass:[UIViewController class]]) {
return (UIViewController *)responder;
}
responder = [responder nextResponder];
}
return nil;
}
轉自:掘金 劉小壯
https://juejin.cn/post/6948318786005139493
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/h851CU6lXg-2Cq9_BWdjoQ