- 原文地址:iOS Responder Chain: UIResponder, UIEvent, UIControl and uses
- 原文作者:Bruno Rocha
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:iWeslie
- 校對者:swants
當我用使用 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
傳送的,MyViewController
的 myCustomMethod
也會被呼叫。
當你沒有指定 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
複製程式碼
在上一個 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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。