はじめに (対象読者・この記事でわかること)
この記像は、SwiftでiOSアプリを開発していて「長押しでコピーメニューを出したい」「TableViewのセルごとに違うメニューを表示させたい」と思っている方を対象にしています。 この記事を読むことで、UIMenuControllerの基本仕組みと、なぜかメニューが表示されない理由の切り分け方、複数セル・複数ボタンに対して独自メニューを割り当てる実装パターンを丸ごと身に付けることができます。 私自身、UIMenuControllerの「canPerformAction」の動きに3時間ハマった経験があるので、同じ時間を無駄にしたくない方に特にオススメです。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。 - Swiftの基本的な文法(クラス、プロトコル、@objc属性) - UIKitにおけるGestureRecognizerの使い方 - TableView or CollectionViewのセル自作経験
UIMenuControllerとはなぜ今更学ぶ必要があるのか
UIMenuControllerは、iOS 3.0から存在する「長押しで表示される黒吹き出しメニュー」の本体です。純正のコピー・ペースト・ルックアップなどをカスタマイズして、独自アクションを追加できます。 iOS 13以降、UIEditMenuInteractionが登場し「UIMenuControllerは非推奨」との情報が流れがちですが、iOS 17現在でも動作し、EditMenuInteractionが未対応のオプション(画像メニューなど)も残っているため、使い分けは必須です。 特にTableViewのセル長押しで「お気に入りに追加」「後で読む」「共有」といったアプリ独自メニューを実装したい場合、UIKitではまだUIMenuControllerが最速かつ簡単です。
ステップバイステップで実装してみる
ステップ1:長押し検知+メニュー表示まで最短実装
まずは、UILabelが長押しされたら「Copy」だけが出る最小構成を書いてみます。
Swiftimport UIKit class ViewController: UIViewController { @IBOutlet private weak var sampleLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() // 1. 長押しジェスチャーを追加 let longPress = UILongPressGestureRecognizer( target: self, action: #selector(showMenu(_:)) ) sampleLabel.addGestureRecognizer(longPress) sampleLabel.isUserInteractionEnabled = true } @objc private func showMenu(_ sender: UILongPressGestureRecognizer) { guard sender.state == .began else { return } // 2. ビューが第一レスポンダになる sampleLabel.becomeFirstResponder() // 3. メニューコントローラーを取得して表示 let menu = UIMenuController.shared let copyItem = UIMenuItem(title: "Copy", action: #selector(copyText)) menu.menuItems = [copyItem] menu.showMenu(from: sampleLabel, rect: sampleLabel.bounds) } @objc private func copyText() { UIPasteboard.general.string = sampleLabel.text } // 4. このビューがメニュー表示を許可する override var canBecomeFirstResponder: Bool { true } }
この時点で実行してもメニューは表示されません。理由は「canPerformAction」で制限されているからです。
ステップ2:なぜか表示されない!canPerformActionの正体
UIMenuControllerは内部でcanPerformAction(_:withSender:)を呼び出し、falseが返るとそのメニューを表示しません。
上のコードではcopyTextセレクタが許可されていないため、メニューが空と判断され非表示になるわけです。
以下のようにcanPerformActionをオーバーライドして明示的に許可しましょう。
Swiftoverride func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(copyText) { return true } return super.canPerformAction(action, withSender: sender) }
これで実行 → 見事に黒い吹き出しが出現します。
ステップ3:TableViewセルごとに異なるメニューを割り当てる
次に応用編。複数セルから成るTableViewで、セルごとに「お気に入り」「削除」「共有」などの異なるメニューを表示したいケースを考えます。
Swiftfinal class ItemCell: UITableViewCell { static let reuseID = "ItemCell" var tapHandler: (() -> Void)? // メニュー選択時のクロージャ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) let longPress = UILongPressGestureRecognizer( target: self, action: #selector(showMenu(_:)) ) addGestureRecognizer(longPress) } required init?(coder: NSCoder) { fatalError() } @objc private func showMenu(_ sender: UILongPressGestureRecognizer) { guard sender.state == .began else { return } becomeFirstResponder() let menu = UIMenuController.shared let fav = UIMenuItem(title: "お気に入り", action: #selector(handleFavorite)) let share = UIMenuItem(title: "共有", action: #selector(handleShare)) let del = UIMenuItem(title: "削除", action: #selector(handleDelete)) menu.menuItems = [fav, share, del] menu.showMenu(from: self, rect: bounds) } @objc private func handleFavorite() { tapHandler?() } @objc private func handleShare() { /* 任意実装 */ } @objc private func handleDelete() { /* 任意実装 */ } override var canBecomeFirstResponder: Bool { true } // すべて許可 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { switch action { case #selector(handleFavorite), #selector(handleShare), #selector(handleDelete): return true default: return super.canPerformAction(action, withSender: sender) } } }
ViewController側でセルを生成するタイミングでtapHandlerを注入すれば、セル単位で処理を受け取れます。
ハマった点やエラー解決
1. メニューが画面の端で切れて見えない
UIMenuControllerは自動で矢印方向を変えますが、セルが画面端ぎりぎりだと完全に隠れることがあります。
showMenu(from:rect:)のrectをbounds.insetBy(dx: -20, dy: 0)のように負のInsetにして余白を確保すると回避できます。
2. 複数セルでメニューが重複して表示される
Aセル長押し → スクロール → Bセル長押しで、Aのメニューが残ったままになることがあります。
これはUIMenuControllerがシングルトンなため。新しいセルでメニュー表示前にmenu.hide()を明示的に呼ぶことでリセットできます。
3. iOS 16以降でmenuItemsがリセットされる
iOS 16からUIEditMenuInteractionがデフォルト有効で、UIMenuControllerのmenuItemsが内部で上書きされるタイミングがあります。
回避策はUIEditMenuInteractionを無効にするか、canPerformActionで都度menuItemsを再設定することです。
解決策
私はcanPerformActionで都度再設定するパターンを採用しました。
Swiftoverride func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { // メニューアイテムがリセットされるため再設定 UIMenuController.shared.menuItems = [ UIMenuItem(title: "お気に入り", action: #selector(handleFavorite)), UIMenuItem(title: "共有", action: #selector(handleShare)), UIMenuItem(title: "削除", action: #selector(handleDelete)) ] switch action { case #selector(handleFavorite), #selector(handleShare), #selector(handleDelete): return true default: return super.canPerformAction(action, withSender: sender) } }
これでiOS 16以降でも安定動作を確認しています。
まとめ
本記事では、UIMenuControllerの基礎から「なぜ表示されないか」「TableViewセルごとに異なるメニューを出す」「iOS 16以降のリセット問題」まで実装パターンを網羅的に解説しました。
- 最小構成でも
canPerformActionを忘れると一切表示されない - セル単位で
becomeFirstResponder+クロージャ注入で再利用可能 - iOS 16以降は
menuItemsがリセットされるため都度再設定が必要
この記事を通して、読者の皆さんが「長押しメニュー実装で3時間ハマる」という無駄をゼロにできれば幸いです。 次回は「UIMenuControllerを使わずUIEditMenuInteractionだけで同等のことを実装する」方法を取り上げる予定です。
参考資料
- UIMenuController | Apple Developer Documentation
- Implementing a Custom Menu Item | Apple Developer Documentation
- iOS 16でUIMenuControllerが表示されない時の対処法 | Zenn
