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