feed

2024年02月20日, 編集履歴

SwiftUIのmacOSアプリでTableにコンテキストメニューをつける

Tableの基本的な使い方は「SwiftUI Tableの使い方」を参照。

macOSアプリにSwiftUIのTableを組み込み、副ボタンクリック(右クリック)によるコンテキストメニューをつけた時、テーブル行として表現されているオブジェクトに対して何らかの操作を行うような場合を考える。たとえばリスト表示にしたFinderで、ファイルやフォルダを右クリックしたときにでるコンテキストメニューをイメージしてもらうと良い。

このときTableColumn配下の要素やTableRow.contextMenu(menuItems:)モディファイアをつけてしまいがちだが、macOSアプリのテーブルに対するコンテキストメニューの自然な挙動を実現するには、Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつけなければならない。

テーブルの選択状態と操作対象

テーブル行をふつうにクリックした場合、行選択の状態となり、行全体にハイライトがつく。行を右クリックした場合、行の縁だけハイライトがつき、コンテキストメニューの起点、操作対象の状態になる。このふたつは別の状態である。選択範囲とコンテキストメニューの操作対象は重なる場合もあれば、異なる場合もある。

コンテキストメニューの操作対象は以下のようになる:

上図のコンテキストメニューの内容から操作対象が変わっていることが解る。

実装

以下のような実装が良いと思う。

import SwiftUI
import UniformTypeIdentifiers

struct Bookmark: Identifiable {

  var id = UUID()
  var title: String
  var url: URL
}

struct ContentView: View {

  @State private var sampleData: [Bookmark] = [
    .init(title: "Genji App", url: URL(string: "https://genjiapp.com")!),
    .init(title: "Apple", url: URL(string: "https://www.apple.com")!),
    .init(title: "Google", url: URL(string: "https://www.google.com")!)
  ]
  @State private var selectedIDs = Set<Bookmark.ID>()

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedIDs) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
//          .contextMenu {
//            Button("ここじゃない") {
//              print(bookmark)
//              print(selectedIDs)
//            }
//          }
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
//          .contextMenu {
//            Button("ここでもない") {
//              print(bookmark)
//              print(selectedIDs)
//            }
//          }
      }
    }
    .tableStyle(.bordered)
    // ここ!
    .contextMenu(forSelectionType: Bookmark.ID.self) { clickedRowIDs in
      addButton
      Button("Delete Bookmark") {
        sampleData.removeAll { clickedRowIDs.contains($0.id) }
      }
    }
    // ここはテーブル内余白を右クリックしたとき
    .contextMenu {
      addButton
    }
    .padding()
  }

  var addButton: some View {
    Button("Add Bookmark") {
      sampleData.append(.init(title: "Microsoft", url: URL(string: "https://www.microsoft.com")!))
    }
  }

}

TableColumn配下の要素に.contextMenu(menuItems:)モディファイアをつける場合、複数の列があるときはすべてにモディファイアをつけなければならないし、右クリックに反応するのは行の中の文字がある部分だけになる。

TableRow.contextMenu(menuItems:)モディファイアをつける場合、選択行とコンテキストメニューの操作対象両方を取得できるが、両者が別の型になるし、両者の重なり具合を調べて操作対象を自分で計算する必要がある。

Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつける場合、第2引数menuクロージャの引数として、標準的な操作対象となるオブジェクトのidSetで取得できるので、その後の処理がストレートに記述できる。

第1引数forSelectionTypeには、テーブル行を表現するオブジェクトを特定するための型を指定する。オブジェクトはIdentifiableプロトコルに適合させているはずなので、idプロパティの型となる。第3引数primaryActionは、行をダブルクリックした時に実行される。

また、ドキュメントには.contextMenu(forSelectionType:menu:primaryAction:)の第2引数menuクロージャの引数が空のSetの場合はテーブルの余白部分を右クリックしたことになるとあるが、macOS 14.3.1とXcode Version 15.2 (15C500b)の環境ではコンテキストメニューが出ない。上のコード例ではTable自体にもうひとつ別の.contextMenu(menuItems:)をつけることで、余白部分を右クリックした場合をカバーしている。