UIMenuControllerが闇だった 前編

f:id:toshi0383:20200519222043p:plain

こんな感じでカスタム項目(Print this word)を追加するのは簡単なのですが、以下のようなケースを考えます。

  • Copy などの標準項目は隠しつつカスタム項目だけ表示する
  • TextViewごとに表示する UIMenuItem を切り替える

カスタム項目入門

まずは簡単なケースですが、これで良いはずです。

    override func viewDidLoad() {
        super.viewDidLoad()

        UIMenuController.shared.menuItems = [.init(title: "Print this word", action: #selector(ViewController.printThisWord))]
    }

    @objc func printThisWord() {
        guard let range = textView.selectedTextRange, let text = tv.text(in: range),
            !text.isEmpty else {
            return
        }

        print("text: \(text)")
    }

UIMenuController.shared.menuItems = [.init(title: "Print this word", action: #selector(ViewController.printThisWord))] の部分はAppDelegateにあっても動きます。 便利ですねUIResponder

Copy などの標準項目は隠しつつカスタム項目だけ表示する

これはちょっと厄介です。 なぜかというと、 UITextView.canPerformAction(_:withSender:) の標準実装が、以下の3つに対してはtrueを返すからです。

copy: _define _share:
f:id:toshi0383:20200519213418p:plain f:id:toshi0383:20200519213414p:plain f:id:toshi0383:20200519213412p:plain

つまり UITextView のサブクラスを作ればこれらに対してfalseを返せることになります。パッと作るならそれでもいいと思います。

私はもう少しスケールする実装にしたかったので、Method Swizzlingを活用して以下のようなハックを実装してみました。

private var enableMenuItemsKey = 0

extension UITextView {

    enum MenuItem: String, CaseIterable {
        case copy = "copy:"
        case define = "_define:"
        case select = "select:"
        ...(省略)
    }

    var enableMenuItems: [MenuItem]? {
        set {
            objc_setAssociatedObject(self, &enableMenuItemsKey, newValue, .OBJC_ASSOCIATION_RETAIN)
        }
        get {
            return objc_getAssociatedObject(self, &enableMenuItemsKey) as? [MenuItem]
        }
    }

    static func swizzle() {
        do {
            let originalMethod = class_getInstanceMethod(Self.self, #selector(canPerformAction(_:withSender:)))!
            let swizzledMethod = class_getInstanceMethod(Self.self, #selector(swizzle_canPerformAction(_:withSender:)))!

            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
    }

    @objc func swizzle_canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {

        let original = swizzle_canPerformAction(action, withSender: sender)
        guard let enableMenuItems = enableMenuItems else {
            return original
        }

        if enableMenuItems.isEmpty {
            return false // ignore original or result from subclass
        }

        return enableMenuItems.map({ $0.rawValue }).contains(action.description)
            && original
    }

}

なかなかハゲしい感じになりましたが、これで標準項目は隠してカスタム項目のみにするコードがかなりスッキリしました。

f:id:toshi0383:20200519220904p:plain

Copy だけ追加することもできました。

f:id:toshi0383:20200519220803p:plain

しかしまだこの行だけ見ると Print this word どこからきたんだ感がすごいです。。

TextViewごとに表示する UIMenuItem を切り替える

鬼門にやってきました。 異なる画面やUITextViewごとに項目を切り替えたいのですが、 UIMenuController.shared はシングルトンです。 UIMenuController.init() を実装することはできるのですが、ランタイムでエラーになります。

2020-05-19 21:44:44.506837+0900 MenuControllerSample[43094:6685424] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'There can only be one UIMenuController instance.'

これでは、同じ画面に複数のUITextViewがあって、それぞれでカスタム項目を出し分けたい場合の対応に困りそうです。

なおcanPerformActionUIMenuController.shared.menuItems を登録し直すのは事情により禁じ手になります。。 https://github.com/cxa/MenuItemKit/blob/master/MenuItemKit/Swift/Swizzlings.swift#L31 この辺りで無限ループすることがあるので。。まあ第一ちょっと設計として分かりづらいですしね。

まだ色々試している段階なので、今回はここまでにしたいと思います。気になる方は次回の投稿をお待ちください。もしくは既に発明されている便利な車輪があるようでしたらぜひこっそり教えてください。

多分 AppDelegate をEvent Hub的な役割にしてレスポンダチェーンに放り投げるような形がいいのかなーとか考えてます。

それでは!