[譯] iOS 響應者鏈 UIResponder、UIEvent 和 UIControl 的使用

掘金翻譯計劃發表於2019-03-30

當我用使用 UITextField 究竟誰是第一響應者? 為什麼 UIView 像 UIResponder 一樣進行子類化? 這其中的關鍵又是什麼?

在 iOS 裡,響應者鏈 是指 UIKit 生成的 UIResponder 物件組成的連結串列,它同時還是 iOS 裡一切相關事件(例如觸控和動效)的基礎。

響應者鏈是你在 iOS 開發的世界中經常需要打交道的東西,並且儘管你很少需要在除了 UITextField 的鍵盤問題之外直接處理它。瞭解它的工作原理將讓你解決事件相關的問題更加容易,或者說更加富有創造力,你甚至可以只依賴響應者鏈來進行架構。

UIResponder、UIEvent 和 UIControl

簡而言之,UIResponder 例項物件可以對隨機事件進行響應並處理。iOS 中的許多東西諸如 UIView、UIViewController、UIWindow、UIApplication 和 UIApplicationDelegate。

相反,UIEvent 代表一個單一併只含有一種型別和一個可選子類的 UIKit 事件,這個型別可以是觸控、動效、遠端控制或者按壓,對應的子類具體一點可能是裝置的搖動。當檢測到一個系統事件,例如螢幕上的點選,UIKit 內部建立一個 UIEvent 例項並且通過呼叫 UIApplication.shared.sendEvent() 把它派發到系統事件佇列。當事件被從佇列中取出時,UIKit 內部選出第一個可以處理事件的 UIResponder 並把它傳送到對應的響應者。這個選擇過程當事件型別不同的時候也會有所變化,其中觸控事件直接傳送到被觸控的 View,其他種類的事件將會被派發給一個所謂的 第一響應者

為了處理系統事件,UIResponder 的子類可以通過重寫一些對應的方法從而讓它們可處理具體的 UIEvent 型別:

open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)
open func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?)
open func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent?)
open func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?)
open func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?)
open func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?)
open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?)
open func motionCancelled(_ motion: UIEvent.EventSubtype, with event: UIEvent?)
open func remoteControlReceived(with event: UIEvent?)
複製程式碼

在某種程度上,你可以將 UIEvents 視為通知。雖然 UIEvents 可以被子類化並且 sendEvent 可以被手動呼叫,但它們並不真正意味著可以這麼做,至少不是通過正常方式。由於你無法建立自定義型別,派發自定義事件會出現問題,因為非預期的響應者可能會錯誤地 “處理” 你的事件。儘管如此,你仍然可以使用它們,除了系統事件,UIResponder 還可以以 Selector 的形式響應任意 “事件”。

這種方法的誕生給 macOS 應用程式提供了一種簡單的方法來響應 “選單” 的操作,例如選擇、複製還有貼上,因為 macOS 中存在多個視窗使得簡單的代理難以實現。在任何情況下,它們也可用於 iOS 以及自定義操作,這正是類似 UIButton 之類的 UIControl 可以在觸控後派發事件。看一下如下的一個按鈕:

let button = UIButton(type: .system)
button.addTarget(myView, action: #selector(myMethod), for: .touchUpInside)
複製程式碼

雖然 UIResponder 可以完全檢測觸控事件,但處理它們並非易事。 那你要如何區分不同型別的觸控事件呢?

這就是 UIControl 擅長的地方,這些 UIView 的子類把處理觸控事件的過程進行抽象,並揭示了為特定的觸控分配事件的能力。

在內部,觸控此按鈕會產生以下結果:

let event = UIEvent(...) //包含觸控位置和屬性的UIKit生成的觸控事件。
//派發一個觸控事件。
//通過 `hitTest()` 確定哪個 UIView 被 選中。
//因為選擇了 UIControl,所以直接呼叫:
UIApplication.shared.sendAction(#selector(myMethod), to: myView, from: button, for: event)
複製程式碼

當一個特定的目標被髮送到 sendAction 時,UIKit 將直接嘗試在所需的目標上呼叫所需的 Selector,如果它沒有實現直接就崩潰,但是如果目標為 nil 又怎麼辦呢?

final class MyViewController: UIViewController {
    @objc func myCustomMethod() {
        print("SwiftRocks!")
    }

    func viewDidLoad() {
        UIApplication.shared.sendAction(#selector(myCustomMethod), to: nil, from: view, for: nil)
    }
}
複製程式碼

如果你執行它,你會看到即使事件是從沒有 target 的普通 UIView 傳送的,MyViewControllermyCustomMethod 也會被呼叫。

當你沒有指定 target 時,UIKit 將搜尋能夠處理此操作的 UIResponder,就像之前在處理簡單的 UIEvent 示例中一樣。在這種情況下,能夠處理動作與以下 UIResponder 方法有關:

open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool
複製程式碼

預設情況下,此方法只檢查響應者是否實現了實際的方法。 “實現” 方法可以通過三種方式完成,具體取決於你需要多少資訊(這適用於 iOS 中的任何原生 target/action 的控制元件):

func myCustomMethod()
func myCustomMethod(sender: Any?)
func myCustomMethod(sender: Any?, event: UIEvent?)
複製程式碼

現在,如果響應者沒有實現該方法怎麼辦?在這種情況下,UIKit 就會使用以下 UIResponder 方法來確定如何繼續:

open func target(forAction action: Selector, withSender sender: Any?) -> Any?
複製程式碼

預設情況下,這將返回 另一個可能可以 處理所需的操作的 UIResponder。此步驟將重複執行,直到處理完事件或沒有其他選擇為止。但是響應者如何知道把操作的路由導向誰呢?

響應者鏈

如開頭所述,UIKit 通過動態管理 UIResponder 物件的連結串列來處理這個問題。所謂的 第一響應者 只是連結串列的頭節點,如果響應者無法處理特定的事件,則事件被遞迴地傳送給連結串列的下一個響應者,直到某個響應者可以處理該事件或者連結串列遍歷結束。

雖然檢視實際的第一響應者是受 UIWindow 中的私有 firstResponder 屬性的保護,但你可以通過檢查 next 屬性是否有值來檢查任何給定響應者的響應者鏈:

 extension UIResponder {
    func responderChain() -> String {
        guard let next = next else {
            return String(describing: self)
        }
        return String(describing: self) + " -> " + next.responderChain()
    }
}

myViewController.view.responderChain()
// MyView -> MyViewController -> UIWindow -> UIApplication -> AppDelegate
複製程式碼

[譯] iOS 響應者鏈 UIResponder、UIEvent 和 UIControl 的使用

在上一個 UIViewController 處理 action 的例子中,UIKit 首先將事件傳送給 UIView 第一響應者,但由於它沒有實現 myCustomMethod,view 將事件發給下一個響應者,正好下一個 UIViewController 實現了所需方法。

雖然在大多數情況下,響應者鏈符合子檢視的結構順序,但你可以對其進行自定義以更改常規流程順序。除了能夠重寫 next 屬性以返回其他內容之外,你還可以通過呼叫 becomeFirstResponder() 強制 UIResponder 成為第一響應者,並通過呼叫 resignFirstResponder() 來取消。這通常與 UITextField 結合使用以顯示鍵盤,UIResponders 可以定義一個可選的 inputView 屬性,使得鍵盤僅在它是第一響應者時顯示。

響應者鏈自定義用途

雖然響應者鏈完全由 UIKit 處理,但你可以使用它來幫助解決通訊或代理中的問題。

在某種程度上,您可以將 UIResponder 的操作視為一次性通知。想想任何一個應用程式,幾乎每個 view 都可以新增閃爍效果。來導航使用者在教程中如何操作。當觸發此操作時,如何確保只有當前活動的檢視閃爍呢?可能的解決方案如下之一是使每個 view 遵循一個協議,或者使用除了 "currentActiveView" 之外每個 view 都需要忽略的通知,但響應者操作允許你不通過代理並用最少的編碼來實現這一點:

final class BlinkableView: UIView {
    override var canBecomeFirstResponder: Bool {
        return true
    }

    func select() {
        becomeFirstResponder()
    }

    @objc func performBlinkAction() {
        //閃爍動畫
    }
}

UIApplication.shared.sendAction(#selector(BlinkableView.performBlinkAction), to: nil, from: nil, for: nil)
//將精確地讓最後一個呼叫了 select() 的 BlinkableView 進行閃爍。
複製程式碼

這與常規通知非常相似,不同之處在於通知會觸發註冊它們的每個物件,而這個方法只會觸發在響應鏈上最先被查詢到的 BlinkableView 物件。

如前所述,甚至可以用此方法進行架構。這是 Coordinator 結構的框架,它定義了一個自定義型別的事件並將自身注入到響應者鏈中:

final class PushScreenEvent: UIEvent {

    let viewController: CoordenableViewController

    override var type: UIEvent.EventType {
        return .touches
    }

    init(viewController: CoordenableViewController) {
        self.viewController = viewController
    }
}

final class Coordinator: UIResponder {

    weak var viewController: CoordenableViewController?

    override var next: UIResponder? {
        return viewController?.originalNextResponder
    }

    @objc func pushNewScreen(sender: Any?, event: PushScreenEvent) {
        let new = event.viewController
        viewController?.navigationController?.pushViewController(new, animated: true)
    }
}

class CoordenableViewController: UIViewController {

    override var canBecomeFirstResponder: Bool {
        return true
    }

    private(set) var coordinator: Coordinator?
    private(set) var originalNextResponder: UIResponder?

    override var next: UIResponder? {
        return coordinator ?? super.next
    }

    override func viewDidAppear(_ animated: Bool) {
        //在 viewDidAppear 填寫資訊以確保 UIKit
        //已配置此 view 的下一個響應者。
        super.viewDidAppear(animated)
        guard coordinator == nil else {
            return
        }
        originalNextResponder = next
        coordinator = Coordinator()
        coordinator?.viewController = self
    }
}

final class MyViewController: CoordenableViewController {
    //...
}

//在 app 的起其他任何位置:

let newVC = NewViewController()
UIApplication.shared.push(vc: newVC)
複製程式碼

這讓 CoordenableViewController 都持有對其原始下一個響應者(window)的引用,但是它重寫了 next 讓它指向 Coordinator,而後者又將 window 指向下一個響應者。

// MyView -> MyViewController -> **Coordinator** -> UIWindow -> UIApplication -> AppDelegate
複製程式碼

這允許 Coordinator 接收系統事件,並通過定義一個新的包含了有關新 view controller 資訊的 PushScreenEvent,我們可以呼叫由這些 Coordinators 處理的 pushNewScreen 事件來重新整理螢幕。

有了這個結構,UIApplication.shared.push(vc: newVC) 可以在 app 中的 任何地方 呼叫,而不需要單個代理或單例,因為 UIKit 將確保只通知當前的 Coordinator 這個事件,這得多虧了響應者鏈。

這裡顯示的例子非常理論化,但我希望這有助於你理解響應者鏈的目的和用途。

你可以在 Twitter 上關注本文作者 — @rockthebruno,有更多建議也可以分享。

官方參考文件

使用響應者和響應者鏈來處理事件

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章