iOS App 程式開發

First Responder 教程:如何使用函式快速取得第一響應者?

First Responder 教程:如何使用函式快速取得第一響應者?
First Responder 教程:如何使用函式快速取得第一響應者?
In: iOS App 程式開發, Swift 程式語言

在寫 iOS app 的時候,我們偶爾會碰到需要用到第一響應者 (First Responder) 的狀況。比如說,當我們想要把內容貼到它上面,或者要它執行復原、重做等動作的時候。然而,Apple 官方並沒有公開可以直接取得第一響應者的 API,只提供了 isFirstResponder 這樣一個屬性讓我們去檢查某個響應者是不是第一而已。那麼,如果我們不知道誰會是第一響應者的話 ── 比方說在 AppDelegate 裡的時候 ── 我們要怎麼取得它呢?

先來複習一下甚麼是響應者:響應者是 UIKit 裡面負責接收使用者事件的一種元件,其類型是 UIResponder。它的子類型除了負責顯示的 UIView 之外,也包含了 UIViewControllerUIApplicationAppDelegate 等不負責顯示的類型。也就是說,以上這些類型全部都具備了對使用者事件作出反應的能力。甚麼樣的使用者事件呢?就 iOS 裝置來說,包括了觸控螢幕輸入、裝置晃動、鍵盤輸入、以及遙控輸入等。特別的是,用螢幕上的小鍵盤輸入雖然是透過觸控螢幕,但在程式邏輯上是屬於鍵盤輸入的,跟外接鍵盤一樣。

看起來,一個 app 在運行的時候,是會有很多個不同的響應者元件同時存在的,有些會顯示,有些則不會顯示。那麼,當使用者用各種方式去操作這個 app 的時候,這些響應者要怎麼決定是誰去處理輸入的事件呢?Apple 給出的答案是所謂的響應鏈 (Responder Chain),一種由下而上的訊息傳遞路徑。首先,由最接近使用者輸入的響應者去接收輸入訊息,並決定要不要把這個輸入事件傳給比較靠近程式核心的下一個響應者,也就是它的 next 屬性所指向的物件。

請注意以下幾點:

  • UIView 來說,它的 next 可能是它的 superview,也可能是正在控制它的 UIViewController
  • UIViewControllernext 可能是它的 parent?.viewpresentingViewController、或者 window
  • UIWindownextUIApplication,也是大多數使用者事件的終點。
  • 不過如果你的 AppDelegate 是繼承自 UIResponder 的話,那麼它才會是響應鏈的最後一個成員。

在這個響應鏈當中,每一個接到訊息的響應者都可以決定自己要不要處理事件、以及要不要把訊息傳遞下去。是一個非常線性的機制。

responder-chain
(圖片來源:Cocoa Application Competencies for iOS

那麼,甚麼才算是最接近使用者輸入的響應者元件呢?要怎麼樣才可以當響應鏈的開端呢?

如果是螢幕觸控輸入的話很簡單,就是在螢幕上點到甚麼 view,它就是響應鏈的開端。「點到甚麼 view」是透過一個叫做點擊測試 (hit test) 的機制去判斷的,不過這裡先略過。

其它種類的輸入的話就比較不那麼直觀了。比如說搖晃裝置的時候,或者用鍵盤輸入的時候。怎麼辦呢?這就是今天這篇文章的主角 ── 第一響應者 ── 被設計出來的原因了。當碰到這種無法從顯示階層決定誰先接收使用者事件的情況時,程式就必須要特別去指定響應鏈的開端。而這個被指定出來的開端響應者,就被稱作第一響應者。而當一個響應者獲得第一響應者的地位之後,接下來所有的鍵盤輸入與搖晃動作事件等都會第一個傳給它。

現在,我們複習了第一響應者的定義。那麼,回到最初的問題:我們要怎麼樣取得 app 裡的第一響應者呢?

如何取得 App 的第一響應者

在實作上,第一響應者其實是 UIWindow 的一個屬性;也就是說,每個視窗都可能有一個自己的第一響應者。然而,一個程式同時只能有一個第一響應者在運作,而這個第一響應者是由 app 的主視窗所持有的;也就是說,一個視窗必須要成為主視窗,它的第一響應者才會開始接收訊息。所以照理說,我們只要取得主視窗就可以取得第一響應者了,但由於該屬性是私有屬性,所以我們必須用 NSSelectorFromString(_:) 去存取它:

extension UIWindow {

    var firstResponder: UIResponder? {
        let firstResponderRef = perform(NSSelectorFromString("firstResponder"))

        return firstResponderRef?.takeRetainedValue() as? UIResponder
    }

}

而因為使用了 Apple 的私有 API,在送審 App Store 的時候很可能會被拒絕,所以這並不是一個很實際的方法。

再來,在 StackOverflow 上面,可以看到用遞迴方式由上而下地檢查所有 view 的解法

extension UIView {

    var firstResponder: UIView? {
        if isFirstResponder {
            return self

        } else {
            return subviews.first(where: { $0.firstResponder != nil })?.firstResponder
        }
    }

}

由於 UIWindowUIView 的子類型,所以也可以從視窗去呼叫 firstResponder。然而,這個方法只涵蓋了 UIView,並沒有考慮到 UIViewControllerUIApplication 等類型。怎麼解決呢?我們可以用類似的方法去涵蓋它們的情況,但這會使整個 firstResponder 的宣告變得很龐大。幸好,UIApplication 提供了一個方法 ── sendAction(_:to:from:for:),這樣我們就可以直接對第一響應者傳送訊息。實際的運用方式可以在這個 StackOverflow 回答裡看到:

extension UIResponder {

    private static weak var _first: UIResponder?

    static var first: UIResponder? {
        _first = nil

        UIApplication.shared.sendAction(#selector(UIResponder.reportAsFirst), to: nil, from: nil, for: nil)

        return _first
    }

    @objc private func reportAsFirst() {
        UIResponder._first = self
    }

}

當我們呼叫 UIResponder.first 的時候,該計算型屬性實際上是利用 UIApplicationsendAction(_:to:from:for:) 方法,要求第一響應者把自己的參照存到 UIResponder._first 型別屬性裡面,再傳回該型別屬性的值。這個方式免去了寫一堆判斷式的麻煩,而且讓我們不用從視窗去找第一響應者,而是把它做成單例 (singleton) 的樣子。

然而,這還是有改進的地方。首先,它用了 UIApplication.shared 這個在擴充套件裡不安全的 API。如果要把這段程式碼放在可以被擴充套件用的 framework 裡面,我們必須想辦法不使用 UIApplication.shared 才行。而最好的替代之處,就是直接放到 UIApplication 裡面:

extension UIApplication {

    private static weak var _firstResponder: UIResponder?

    var firstResponder: UIResponder? {
        _firstResponder = nil

        sendAction(#selector(UIResponder.reportAsFirst), to: nil, from: nil, for: nil)

        return _firstResponder
    }

}

extension UIResponder {

    @objc fileprivate func reportAsFirst() {
        UIApplication._firstResponder = self
    }

}

如此一來,我們就可以避免對 UIApplication.shared 的呼叫了。實際上使用時,我們就變成在 AppDelegate 裡呼叫 application.firstResponder,或者在其它地方呼叫 UIApplication.shared.firstResponder

接著,它用了 _firstResponder 這個儲存型屬性。雖然已經將其設定為 private,但仍然是一個可變狀態,讓程式的狀態複雜度上升了一點。那麼,有辦法只使用函式來寫 firstResponder 嗎?有的,我們可以改將指派 _firstResponder 的過程包到一個閉包裡,並將該閉包當成 sender 一併傳給第一響應者的 reportAsFirst(_:) 方法:

extension UIApplication {

    var firstResponder: UIResponder? {
        var _firstResponder: UIResponder?

        let reportAsFirstHandler = { (responder: UIResponder) -> Void in
            _firstResponder = responder
        }

        sendAction(#selector(UIResponder.reportAsFirst), to: nil, from: reportAsFirstHandler, for: nil)

        return _firstResponder
    }

}

extension UIResponder {

    @objc fileprivate func reportAsFirst(_ sender: Any) {
        if let handler = sender as? (UIResponder) -> Void {
            handler(self)
        }
    }

}

要特別注意的是,reportAsFirstsender 參數的型別不能直接宣告成閉包的型別(這裡是 (UIResponder) -> Void),否則會在執行時報錯。所以,這裡我們必須將 sender 宣告成 Any,然後再手動將其轉成閉包型別。

如此一來,我們就寫出了一個從 UIApplication 的物件呼叫,並且完全以函式寫成的取得第一響應者的方法了!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。