UIMenuControllerが闇だった 前編
こんな感じでカスタム項目(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: |
---|---|---|
つまり 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 } }
なかなかハゲしい感じになりましたが、これで標準項目は隠してカスタム項目のみにするコードがかなりスッキリしました。
Copy
だけ追加することもできました。
しかしまだこの行だけ見ると 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
があって、それぞれでカスタム項目を出し分けたい場合の対応に困りそうです。
なおcanPerformAction
で UIMenuController.shared.menuItems
を登録し直すのは事情により禁じ手になります。。 https://github.com/cxa/MenuItemKit/blob/master/MenuItemKit/Swift/Swizzlings.swift#L31 この辺りで無限ループすることがあるので。。まあ第一ちょっと設計として分かりづらいですしね。
まだ色々試している段階なので、今回はここまでにしたいと思います。気になる方は次回の投稿をお待ちください。もしくは既に発明されている便利な車輪があるようでしたらぜひこっそり教えてください。
多分 AppDelegate
をEvent Hub的な役割にしてレスポンダチェーンに放り投げるような形がいいのかなーとか考えてます。
それでは!