[iOS 理解] 響應者鏈

研究好久事件響應的細節,結果一看網上已經有寫的非常好的,於是本文分三個部分:
1 總結
2 我的補充
3 我的原文

如果一點沒了解過響應者鏈,先學習別人寫的這篇文章
如果有了解,或學過之後,本文還有一些補充。

總結

以該文章的一張圖先作大體總結:
在這裏插入圖片描述

針對圖片內容補充

1 source0 回調內初步封裝成 UIEvent,僅含有原始物理數據,UITouch 還沒有生成,然後:
2 hitTest 調用了兩次,具體的作用是,第一次獲得響應者;第二次生成 UITouch,特別是 UITouch 的 gestureRecognizers,也就是構造完整 UIEvent。然後調用自己(UIApplication) 的 sendEvent,函數內調用 UIWindow 對象的 sendEvent。
3 圖裏有句話不通順,UITouch 的 view 就是響應者,UITouch 的 gestureRecognizers,是響應者及其所有祖先視圖 的所有 gestureRecognizers
4 UIWindow 的 sendEvent 內確定響應順序並調用,除圖中順序之外,關於 UIControl 子類,系統實現的如 UIButton,順序是 UIButton上的手勢 先於 UIButton 的 target-action 先於 祖先的手勢
自定義的 UIControl 類,和普通 UIView 一樣。

其他點

1 UIControl 對象 addTarget:action:forControlEvents: 參數 target 可以爲空,按響應者鏈順序判斷能不能響應 action,然後發送。原理是 UIControl sendAction:to:forEvent: 內找到最終有效 target,最終是 UIApplication sendAction:to:from:forEvent: 執行 [target action]
2 vc 持有 button,button 持有一個 targetAction 數組,數組某一項是 UIControlTargetAction 對象,對象內有 _target _action _eventMask _cancelled,而我們 addTarget self,循環引用?實際上 UIControlTargetAction 初始化時用了 weak,所以沒事。
3 scrollView 的事件傳遞,首先 scrollView 是自己實現了 touchesBegan 函數的,同時 scrollView 還有 pinch 手勢、pan 手勢。所以常見的通過 touchesBegan 撤銷鍵盤沒有用,因爲響應者鏈被 scrollView 處理 touchesBegan 後沒有繼續傳遞。然後是滑動與點擊 cell,點擊 cell 是 touchesBegan 實現的,滑動是 pan 手勢,滑動失敗,點擊 cell 才成功;而且 scrollView 實現的“點擊 cell”有很多細節判定,比如正在滑動時,點擊是停止滑動,等等。
4 又想起一些 scrollView 其他細節
4.1 delaysContentTouches 是指 0.15 秒後纔開始發送 touchesBegan,他實際上是一個手勢,我猜測是利用 delaysTouchesBegan 失敗前不能發送,同時爲了別的手勢可以成功,這個手勢在 0.15 秒之後直接失敗。pan 手勢如果在這 0.15 秒內識別成功,則子視圖什麼消息都收不到。delaysContentTouches 爲 0 則和普通視圖一樣了。
4.2 canCancelContentTouches 這個很常見,比如 cell 裏有一個子 view,手指放上去一小段時間然後滑動,view 必定先 began,開始滑動後 cancel。如果 canCancelContentTouches 爲 0,則只要 view touchesBegan 了(即 pan 手勢在 0.15 秒內沒成功),pan 手勢失敗,就不能滑動了。(4.1 4.2 都爲 false,則 pan 手勢永遠失敗,不能滑動)。

這屬於 scrollView 確定交互意圖的部分,這部分內容 不要在 tableView 上測試,我真的測到懷疑人生,後來看到這篇文章的評論區才明白一點。。



我寫的爛文

交互方式

目前有(未來可能有其他方式):

  • Touch 觸摸
  • Press 按壓,物理按鈕
  • Motion 運動,搖一搖
  • Remote-Control 遠程控制,AirPods

以上交互,都會產生用戶事件。本文僅以第一種作例子,觸類旁通。

觸摸屏幕

硬件事件(觸摸/鎖屏/搖晃等)發生後,首先由 IOKit.framework 生成一個 IOHIDEvent 事件並由 SpringBoard 接收。然後利用進程間通信 mach port,發給前臺 App 進程。App 內 runloop 有等待該 port 的 Source1,觸發回調 __IOHIDEventSystemClientQueueCallback,回調內觸發 Source0,Source0 回調 __handleEventQueueInternal,回調內把 IOHIDEvent 初步封裝* 爲 UIEvent,進行下一階段。


UIEvent: 用來描述一個事件。交互類型,產生時間等。
UITouch: 用來描述一次觸摸。除了物理上的位置、移動、力度等,還有所在 UIView、UIWindow 等。
初步封裝? 觸摸產生的 UIEvent 對象正常情況下應該包含所有 UITouch(一個手指一個)的詳細數據。然而此時的 UIEvent,僅含有操作系統傳來的原始物理數據,或者說,真正的 UITouch 還沒有生成。此時打印:<UITouchesEvent: 0x610000104a40> timestamp: 15125.6 touches: {(空!)}
UIResponder: 表示可以響應、處理事件的抽象類,響應者。先了解 UIApplication 繼承自 UIResponder,UIWindow 繼承自 UIView,UIView 繼承自 UIResponder。


摸到誰了

UIApplication 對象發現初步封裝的 UIEvent 對象的交互類型是觸摸,需要知道摸到的是哪個響應者,然後讓該響應者去響應。於是發消息給 UIWindow(繼承自 UIView)對象,hitTest:withEvent:其返回值 UIView 就是被摸到的響應者。

hitTest:withEvent:代碼如下,註釋的地方後面有解釋。第一輪hitTest調用。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1
    // 2
    if (![self pointInside:point withEvent:event])
        return 0;
    
    for (UIView * child in [self subviews]) {
        // 3
        if (![child isUserInteractionEnabled] || [child isHidden] || [child alpha] < 0.01)
            continue;
        // 4
        CGPoint converted = [child convertPoint:point fromView:self];
        UIView* found = [child hitTest:converted withEvent:event];
        if (found)
            return found;
    }
    // 5
    return self;
}

這段代碼比別的博客裏的僞碼準確的多。

再回顧一下函數目的:根據觸摸的 CGPoint(UIWindow 座標系下),得到視圖樹上儘可能遠的響應者。利用遞歸實現。

代碼註釋

  1. 此時的 event 仍然沒有生成 UITouch。
  2. 子視圖超出父視圖範圍的部分不響應事件。
  3. 遍歷所有子視圖,需要符合一定條件。
  4. 在子視圖中找,需要先轉換座標系。如果沒找到,就搜下一個子視圖,如果找到了就直接返回。
  5. 所有子視圖都沒有,只能是當前視圖了。

最終返回觸摸的最遠層級的 UIView(UIResponder)。

總邏輯如下:
在這裏插入圖片描述

構造完整的 UIEvent

現在已經知道觸摸的是哪個 UIView(UIResponder),還沒有構造 UITouch。
構造完整 UITouch 的具體細節很難搞出來,但是至少能有個輪廓:UIWindow 對象調用了 convertPoint:toWindow:convertPoint:fromWindow:各5次,後面又完完整整的走了一遍上面的 hitTest:withEvent:,這一遍 hitTest 是完善 UITouch 中的 view(響應者)、gestureRecognizers(整個鏈中包含的視圖的所有) 等屬性,這樣終於完善了 UIEvent(也就是將來 touchesBegan 的參數之一)。

告訴響應者?

UIApplication 終於包裝好了此次事件,調用自己的 sendEvent: 函數,函數內調用 UIWindow 對象的 sendEvent 函數(注意不要混了),UIWindow 的 sendEvent 函數內把 UIEvent、所有 UITouch 首先發給響應者的 gestureRecognizer 們,怎麼發的?就是最熟悉的 touchesBegan,看看他們能不能識別成功,如果沒有一個識別成功,最後纔到響應者 touchesBegan。一旦某個 gestureRecognizer 識別成功*,把這個 gestureRecognizer 標記爲待處理,對響應者根本不管了。

有一個 Observer 監測 BeforeWaiting (Runloop 即將進入休眠) 事件,這個 Observer 的回調函數是 _UIGestureRecognizerUpdateObserver,其內部會獲取所有剛被標記爲待處理的 GestureRecognizer,並執行 GestureRecognizer 的回調。

爲什麼有 gestureRecognizer ?

有一些常用操作,比如長按,如果多個視圖需要這個操作,都要重寫自己的 touchesBegan 系列函數。這時爲了代碼複用,設計者就設計出手勢識別器,改變 sendEvent 內部實現:去檢查是否有手勢識別器可以識別。

響應者鏈

如果最遠層響應者沒有實現處理函數,UIResponder 默認會調用 nextResponder 的處理函數(如果不存在 nextResponder 則 sendEvent 函數返回)。


nextResponder 是 UIResponder 的一個屬性,規則:一個 UIResponder 如果有容器或控制器(如 UIView 與 UIViewController),則返回該容器,容器的 nextResponder 爲該 UIResponder 的父視圖;沒有容器則返回該 UIResponder 的父視圖。(沒有父視圖的返回 nil)。最終一定會歸結到 UIWindow 上(UIWindow 屬於 UIView,最終的父視圖一定是 UIWindow)。UIWindow 的 nextResponder 爲 UIApplication,UIApplication 的 nextResponder 爲 AppDelegate(也繼承自 UIResponder),AppDelegate 的 nextResponder 爲 nil,sendEvent 函數返回。


如果實現了處理函數,即響應者處理了事件。至於要不要再轉發給 super,取決於業務。

響應者與 InputView

一個視圖如果被 hitTest 返回作爲響應者,他就收到消息: becomeFirstResponder,如果他可以成爲 first,那他就成爲 first,同時 如果他有 inputView,inputView 就會顯示。

附:子視圖超出父視圖範圍 無法響應點擊事件解決辦法

one solution:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {

   for (UIView * child in [self subviews]) {

       if (![child isUserInteractionEnabled] || [child isHidden] || [child alpha] < 0.01)
           continue;

       CGPoint converted = [child convertPoint:point fromView:self];
       UIView* found = [child hitTest:converted withEvent:event];
       if (found)
           return found;
   }

   if ([self pointInside:point withEvent:event])
       return self;

   return 0;
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章