天天看點

[iOS 了解] 響應者鍊

研究好久事件響應的細節,結果一看網上已經有寫的非常好的,于是本文分三個部分:

1 總結

2 我的補充

3 我的原文

如果一點沒了解過響應者鍊,先學習别人寫的這篇文章;

如果有了解,或學過之後,本文還有一些補充。

總結

以該文章的一張圖先作大體總結:

[iOS 了解] 響應者鍊

針對圖檔内容補充

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_reversed]) { // 子視圖逆序 
        // 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)。

總邏輯如下:

[iOS 了解] 響應者鍊

構造完整的 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;
}