xyk blog

最近は iOS 開発の記事が多めです。

Swift の Protocol Extension 内で Selector を呼び出す

検証環境:
Xcode 11.6
Swift 5.2.4

Swift の Protocol Extension 内では @objc をつけたメソッドを実装しても #selector で呼び出しすることはできません。

例えば以下のコードでは NotificationCenter の引数で selector を指定しています。

protocol KeyboardDetector {}

extension KeyboardDetector where Self: UIViewController {
    
    func startObservingKeyboardChanges() {
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(onKeyboardWillShow(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil)
    }
    
    @objc func onKeyboardWillShow(_ notification: Notification) {
        // do stuff
    }
}

しかし selector を指定する部分で、

Argument of '#selector' refers to instance method 'onKeyboardWillShow' that is not exposed to Objective-C

というエラーが、また @objc のメソッド定義部分にも

@objc can only be used with members of classes, @objc protocols, and concrete extensions of classes

というエラーになってしまいます。
どうやら Protocol Extension 機能は Objective-C からは見えないようです。

なんとか使うことはできないのかとググってみたところ、Objective-C の Associated Objects を使って実装する方法があったので、ちょっと強引ですがそれで実装してみます。

Swift の Protocol Extension 内で Selector を呼び出す

まず NotificationCenter の引数に渡す Observer 用クラスを作ります。
@objc メソッドはこの中に定義し、実際の処理は外部からクロージャで渡すようにします。

class NotificationObserver {
    
    let closure: (_ notification: Notification) -> Void

    init(closure: @escaping (_ notification: Notification) -> Void) {
        self.closure = closure
    }

    @objc func invoke(_ notification: Notification) {
        closure(notification)
    }
}

通常 NotificationCenter の Observer には self (UIViewController) を渡すところですが、今回は @objc メソッドを定義した NotificationObserver クラスのインスタンスを関数スコープ内で生成し、NotificationCenter の Observer として渡します。

このままだと Observer インスタンスが関数終了時に消滅してしまいますが、これを回避するために、objc_setAssociatedObject を使って保持するようにします。

protocol KeyboardDetector {}

extension KeyboardDetector where Self: UIViewController {
    
    func startObservingKeyboardChanges() {
        
        let observer = NotificationObserver { [weak self] notification in
            self?.onKeyboardWillShow(notification)
        }
        
        NotificationCenter.default.addObserver(
            observer,
            selector: #selector(NotificationObserver.invoke(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil)
        
        objc_setAssociatedObject(
            self,
            "[\(arc4random())]",
            observer,
            .OBJC_ASSOCIATION_RETAIN)
    }
    
    func onKeyboardWillShow(_ notification: Notification) {
        // do stuff
    }
}

以下が参考にした記事です。
こちらでは UIControl の addTarget メソッドの引数に渡す selector の例です。

stackoverflow.com

blog.natanrolnik.me