Mac の項目リストでは、選択状態を SwiftUI の List にバインドし、選択に対応したコンテキストメニューからプライマリアクションを指定します。シングルクリックで行を選択し、ダブルクリックで開きます。選択状態と表示状態は分けて管理します。

このチュートリアルでは、3 つの合成されたインメモリ項目を使い、独自の macOS アプリ内でこの操作を実装します。また、カスタムの SwiftUI サーフェスや AppKit ビューを使う場合の境界も説明するので、同じクリックを複数のレイヤーが奪い合わない方法を選べます。

選択可能なコンテナーのプライマリアクションを使う

Apple は macOS で、選択可能なコンテナーにおけるシングルクリックでの選択と、ダブルクリックでのプライマリアクションを明示的に文書化しています。SwiftUI でサポートされている入口は contextMenu(forSelectionType:menu:primaryAction:) で、macOS 13 以降で利用できます。List や Table など、選択をサポートするコンテナーにアタッチします。Apple の選択対応コンテキストメニューのドキュメント。

役割は 3 つに分かれています。モデルが各項目を識別し、リストがバインディングを通じて選択状態を保持し、プライマリアクションのハンドラーが開く操作の内容を決めます。この例では、開くと項目の詳細シートを表示します。項目を選択すると、行のハイライトと選択ラベルだけが変わります。

操作を担当するビューに応じて、実装方法を選びます。

インターフェイス 適切な開始方法 維持する責任
macOS 13 以降の SwiftUI 項目リストまたはテーブル 選択バインディングと選択対応のプライマリアクション アクション ID を解決し、対応するコンテンツを表示する
カスタムの SwiftUI タイルまたはシェイプ 明示的なジェスチャーの合成 選択時の表示、アクティベーション方針、その他の入力経路
SwiftUI に埋め込まれた AppKit テーブル NSTableView の選択と doubleAction 選択とアクティベーションを SwiftUI の状態に橋渡しする
カスタムの AppKit 描画サーフェス 独自のマウスイベント処理 ヒットテスト、項目の識別、クリック連続の方針

ここでのタスクには最初の方法が直接当てはまります。カスタム方法は、インターフェイスに別のサーフェスが本当に必要な場合に便利ですが、より多くの操作動作を自分で実装する必要があります。Apple は、基盤となる代替方法をジェスチャーの合成、AppKit テーブルのダブルアクション、マウスダウンの処理で説明しています。

単一選択の例を完成させる

Xcode で SwiftUI インターフェイスを使った macOS アプリプロジェクトを作成し、デプロイメントターゲットを macOS 13 以降に設定します。生成されたアプリとコンテンツビューの宣言を、次の単一ファイルで置き換えます。ターゲット内の @main アプリ宣言は 1 つだけにしてください。

このプログラムは、固定の整数 ID、3 色の円、ローカル状態を使います。ファイルを読み込まず、外部リソースも開きません。ツールバー風の Open ボタンとコンテキストメニューの Open コマンドは、ダブルクリック時のアクティベーションと同じ関数を呼び出します。

import SwiftUI

struct SampleItem: Identifiable {
    let id: Int
    let name: String
    let tint: Color
}

@main
struct ClickSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemBrowser()
        }
    }
}

struct ItemBrowser: View {
    private let items = [
        SampleItem(id: 1, name: "Circle A", tint: .blue),
        SampleItem(id: 2, name: "Circle B", tint: .orange),
        SampleItem(id: 3, name: "Circle C", tint: .purple)
    ]

    @State private var selectedID: SampleItem.ID? = nil
    @State private var openedItem: SampleItem? = nil
    @State private var message = "Select an item."

    private var selectedItem: SampleItem? {
        items.first { $0.id == selectedID }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(selectedItem.map {
                    "Selected: \($0.name)"
                } ?? "No selection")
                Spacer()
                Button("Open") {
                    if let id = selectedID {
                        openItems([id])
                    }
                }
                .disabled(selectedItem == nil)
            }

            List(items, selection: $selectedID) { item in
                HStack(spacing: 10) {
                    Circle()
                        .fill(item.tint)
                        .frame(width: 18, height: 18)
                    Text(item.name)
                }
                .padding(.vertical, 4)
            }
            .contextMenu(forSelectionType: SampleItem.ID.self) { ids in
                if ids.count == 1 {
                    Button("Open") {
                        openItems(ids)
                    }
                }
            } primaryAction: { ids in
                openItems(ids)
            }

            Text(message)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 360, minHeight: 280)
        .sheet(item: $openedItem) { item in
            VStack(spacing: 16) {
                Circle()
                    .fill(item.tint)
                    .frame(width: 80, height: 80)
                Text(item.name)
                    .font(.title2)
                Button("Close") {
                    openedItem = nil
                }
            }
            .padding(32)
            .frame(minWidth: 260, minHeight: 220)
        }
    }

    private func openItems(_ ids: Set<SampleItem.ID>) {
        guard ids.count == 1, let id = ids.first else {
            message = "Choose exactly one item to open."
            return
        }
        guard let item = items.first(where: { $0.id == id }) else {
            message = "That item is no longer available."
            return
        }
        openedItem = item
        message = "Opened: \(item.name)"
    }
}

宣言と参照は確認済みですが、利用可能な環境には Swift ツールチェーンも macOS の GUI もないため、この例のコンパイルや実行はここでは行っていません。Xcode でビルドし、以下の操作を確認してください。

ID、選択、オープンを分けて管理する

SampleItem は Swift の Identifiable プロトコルに準拠しています。固定の id は、ビューの更新後も同じ項目を表します。Apple はこのプロトコルを安定した識別性に基づいて定義しており、表示名は別のプロパティです。より大きなアプリでは、名前や見た目が変わっても項目の ID を維持してください。Apple の Identifiable のドキュメント。

オプショナルな selectedID によって、単一選択のリストになります。ID のセットを使うと、複数選択が有効になります。SwiftUI のリストのドキュメントでは、これら 2 種類のバインディング形式と、コンテナーが提供する選択時の表示について説明されています。Apple の List のドキュメント。

openedItem は表示を独立して制御します。これに項目を設定するとシートが表示され、nil を設定すると閉じます。これは、項目に基づくシート API のドキュメントに沿った使い方です。行の選択では openedItem が一度も代入されないため、この実装では選択だけでシートが表示されることはありません。Apple の項目ベースのシートのドキュメント。

アクションに渡された ID を解決する

コンテキストメニュー API はコンテナーの識別子型を受け取り、アクションには ID のセットが渡されます。そのため、この例ではリストの選択と一致する SampleItem.ID.self を使います。別途読み取った選択値で置き換えず、コールバックの引数を解決します。Apple のコンテキストメニューのパラメーターに関するドキュメント。

このハンドラーが受け付ける ID は、意図的に 1 つだけです。空のセットにはメッセージを表示し、解決できない ID にも別のメッセージを表示します。後で複数選択を有効にする場合は、このガードを変更する前に、開く方針を決めてください。グループの詳細ビューを表示する、個別のウインドウを開く、または選択を 1 つに限定する、といった方法があります。任意の最初のメンバーを使うと、未定義の方針が隠れてしまいます。

2 つのタップハンドラーには別の設計判断が必要な理由

カスタムタイルでは、独自のジェスチャーが必要なことがあります。SwiftUI の onTapGesture(count:perform:) では、クリックまたはタップに必要な回数を指定できます。ただし、そのドキュメントは、2 つの独立した修飾子をビューに付けることで、シングルクリックとダブルクリックを排他的に判定する仕様になるとは説明していません。Apple のタップアクションのドキュメント。

まず、ダブルクリック中の選択の扱いを決めます。一般的な項目ブラウザーでは、コンテナーを使う方法と一貫しており、開く前に項目を選択するのが便利です。「シーケンスがダブルクリックにならなかった場合に限り、シングルクリックのアクションを実行する」という別の要件には、判定の調整が必要です。

カスタムサーフェス向けに、SwiftUI は exclusively(before:) を提供しています。これを使うと、最初のジェスチャーを優先し、コンポーネントジェスチャーのうち 1 つだけが成功します。次はジェスチャー式の断片であり、リストの例全体を置き換えるものではありません。Apple の排他的ジェスチャーのドキュメント。

TapGesture(count: 2)
    .exclusively(before: TapGesture(count: 1))

これにより、ダブルクリックの認識が優先されます。合成したジェスチャーをカスタム項目にアタッチし、選択とオープンの方針に応じて結果を処理します。TapGesture は、指定されたタップ回数による認識を定義しています。Apple の TapGesture のドキュメント。

この方針には時間に関する影響があります。シーケンスがシングルクリックだけだと判定するには、ダブルクリックではないと見極める必要があります。これは操作要件から生じるもので、SwiftUI に固定の遅延が文書化されているという意味ではありません。ダブルクリックのシーケンス中には決して実行されないことを求めながら、シングルクリックが即座に完了すると約束するのは避けてください。

カスタムタイルでは、クリック可能な範囲も定義してください。contentShape(Rectangle()) を使うと、パディングのある矩形領域をヒットテストの対象にできますが、ジェスチャーの優先順位は決まりません。クリックを取りこぼす原因を調べる際は、この 2 つを別々に扱ってください。Apple のコンテンツシェイプのドキュメント。

AppKit が関わる場合もイベントの所有権を保つ

既存の AppKit テーブルが行を所有している場合は、そのテーブルがサポートするアクティベーション機構を使います。NSTableView.doubleAction は、ダブルクリック時にターゲットへセレクターを送信します。Apple は、見出しを含め、アクティベーションが発生した場所を判断するための clickedRow と clickedColumn も説明しています。開く前に、その位置が項目を示していることを検証してください。Apple の doubleAction のドキュメント。

カスタムの AppKit サーフェスでは、mouseDown(with:) がマウスの左ボタンのダウンイベントを受け取り、NSEvent.clickCount はマウスのダウンイベントまたはアップイベントに関連付けられたクリック回数を報告します。無関係なイベントでこのプロパティを読み取ることは、文書化された契約の範囲外であり、例外が発生する場合があります。この解釈は、適切なマウスイベントを処理するビュー内に保ってください。Apple のマウスダウンのドキュメント、Apple のクリック回数のドキュメント。

AppKit が所有するビューを SwiftUI に埋め込むには、NSViewRepresentable を使います。makeNSView でビューを作成し、updateNSView で現在の構成を同期します。ターゲットアクションやデリゲートとの通信を橋渡しする必要がある場合は、コーディネーターを使います。Apple は、作成、更新、調整、解体を、この連携を構成する要素として説明しています。Apple の NSViewRepresentable のドキュメント。

マウスイベントを受け取るオーバーレイを重ねると、どのビューがクリックを処理するかが変わることがあります。AppKit はヒットテストを使ってマウスダウンイベントの受信先を決めるため、イベントを捕捉するオーバーレイを、見えない監視役として扱うことはできません。このタスクでは、認識処理を選択可能なコンテナー、または項目の操作を実際に所有するカスタムサーフェスに置いてください。Apple のヒットテストのドキュメント。

選択とオープンを別々の結果として確認する

例をビルドした後、次の手順で確認します。

  1. Circle A を 1 回クリックします。その行が選択され、選択ラベルに名前が表示され、シートは表示されないことを確認します。
  2. Circle B をダブルクリックします。詳細シートに Circle B が表示されることを確認します。
  3. シートを閉じて Circle C を 1 回クリックします。別のシートを開かずに選択が変わることを確認します。
  4. 表示されている Open ボタンをアクティブにします。同じハンドラーを通じて選択中の項目が開くことを確認します。
  5. 行のコンテキストメニューから Open コマンドを実行し、開いた項目を確認します。
  6. リストの未使用スペースをクリックまたはダブルクリックします。関係のない項目が開かないことを確認します。
  7. 埋め込みコントロール、ドラッグ、オーバーレイを追加した場合は、それらの正確な領域でも確認を繰り返します。

選択時に詳細が予期せず開く場合は、表示状態がどこで設定されるかを調べてください。アクティベーションで別の項目が開く場合は、コールバックの ID とモデルの検索処理を調べてください。カスタムタイルの一部でクリックを取りこぼす場合は、ジェスチャーの優先順位を変える前に、レイアウトとヒットテストのシェイプを確認してください。これらの確認を行えば、状態、アクションの対象指定、イベントの配信を一度に変えず、それぞれ個別に調べられます。

一般的な Mac の項目リストでは、選択バインディングとコンテナーのプライマリアクションを中心に実装してください。サーフェスや操作上の要件がある場合にだけカスタム認識を加え、すべてのオープン経路で、明示的な 1 つのハンドラーを通じて対象の項目を解決してください。

出典