親のドラッグを simultaneousGesture(_:including:) でアタッチすると、子ビューの操作と並行して認識させることができます。クリック時のアクションは子ビューのボタンに持たせ、ドラッグには移動しきい値を設け、ドラッグ中に選択も許可するかどうかを明示的に決めます。

このチュートリアルでは、合成した3つのアイテムボタンを含む、移動可能なパネルを作成します。ここで扱う操作は明確です。アイテムをクリックすると選択され、パネルをドラッグすると同じMacアプリ内で位置が変わります。この例では選択と移動の両方を可能にします。別の領域にハンドルを設ける方法も紹介します。こちらは操作領域を分けたい場合に適しています。

ジェスチャの優先順位を変える前に、操作のルールを決める

Appleは simultaneousGesture(_:including:) を、既存のジェスチャと同じ優先順位でジェスチャを追加するものとして説明しています。その例では、子ビューのタップハンドラーと、それを囲むコンテナのタップハンドラーが、どちらもクリックに反応します。親が子ビューより意図的に優先されることなく操作に参加する場合に適した合成方法です。Appleの同時ジェスチャに関するドキュメント

ただし、同時認識だけで「ドラッグ中はすべての子ビューのアクションをキャンセルする」というルールが表現されるわけではありません。また、Appleの例は、あらゆるボタンスタイルやコンテナがボタンとドラッグを同じように調停することを示しているわけでもありません。ジェスチャの参加方法とアプリの動作は、別々に決めてください。

このパネルでは、パネルのオフセットと選択中のアイテムを親が管理します。各子ビューは、クロージャを通じて選択を要求するボタンを持ちます。同じポインター操作でパネルが動いても、選択要求は有効なままです。移動中のアクティベーションを禁止する必要がある場合は、未文書化のコールバック順序に依存せず、以下のハンドルを使う方法を選んでください。

子ビューのアクションには Button を使います。Appleは、標準のアクセシビリティ動作を含むボタンの動作が必要な操作には、ボタンを使うよう明示的に勧めています。カスタムシェイプをボタンのラベルにしてもかまいません。単独のタップジェスチャにする必要はありません。Appleのジェスチャに関するドキュメント

子ビューのボタンを含む、移動可能なパネルを作成する

XcodeでmacOS SwiftUIアプリを作成し、デプロイメントターゲットをmacOS 14以降に設定して、生成されたアプリとコンテンツビューを次の単一ファイルに置き換えます。ターゲット内のアプリのエントリーポイントは1つだけにしてください。ここで使う最新の coordinateSpace(_:) 修飾子には、macOS 14以降が必要です。Appleの座標空間に関するドキュメント

すべてのアイテムと状態はメモリ上にあります。この例では、固定されたステージ内で、ボタンを含むパネル全体を移動します。アイテムの移動やアプリケーションウィンドウの移動は実装しません。

import SwiftUI

@main
struct GesturePanelApp: App {
    var body: some Scene {
        WindowGroup {
            PanelDemo()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

private struct ItemTarget: View {
    let item: DemoItem
    let isSelected: Bool
    let select: () -> Void

    var body: some View {
        Button(action: select) {
            HStack(spacing: 10) {
                Circle()
                    .fill(item.color)
                    .frame(width: 18, height: 18)
                Text(item.title)
                Spacer()
                if isSelected {
                    Text("Selected")
                        .font(.caption)
                }
            }
            .padding(12)
            .frame(width: 220)
            .background(
                isSelected
                    ? Color.accentColor.opacity(0.18)
                    : Color.gray.opacity(0.12)
            )
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

private struct PanelDemo: View {
    private let items = [
        DemoItem(id: 1, title: "Item A", color: .blue),
        DemoItem(id: 2, title: "Item B", color: .orange),
        DemoItem(id: 3, title: "Item C", color: .purple)
    ]

    @State private var selectedID: Int? = nil
    @State private var committedOffset: CGSize = .zero
    @GestureState private var liveTranslation: CGSize = .zero

    private var pan: some Gesture {
        DragGesture(
            minimumDistance: 8,
            coordinateSpace: .named("stage")
        )
        .updating($liveTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            committedOffset = CGSize(
                width: committedOffset.width + value.translation.width,
                height: committedOffset.height + value.translation.height
            )
        }
    }

    var body: some View {
        VStack(spacing: 12) {
            Text("Click an item or drag the panel")

            ZStack {
                Color.gray.opacity(0.06)

                VStack(spacing: 10) {
                    Text("Drag panel")
                        .font(.headline)
                        .frame(width: 244, height: 32)
                        .contentShape(Rectangle())

                    ForEach(items) { item in
                        ItemTarget(
                            item: item,
                            isSelected: selectedID == item.id,
                            select: { selectedID = item.id }
                        )
                    }
                }
                .padding(16)
                .background(Color.gray.opacity(0.15))
                .contentShape(Rectangle())
                .simultaneousGesture(pan, including: .all)
                .offset(
                    x: committedOffset.width + liveTranslation.width,
                    y: committedOffset.height + liveTranslation.height
                )
            }
            .frame(width: 640, height: 420)
            .coordinateSpace(.named("stage"))

            Button("Reset panel") {
                committedOffset = .zero
            }
        }
        .padding(20)
    }
}

このプログラムは取得したAPIドキュメントに照らして確認しましたが、ここではコンパイルも実際の操作テストも行っていません。この環境にはSwiftコンパイラもmacOSのUIランタイムもありません。サポート対象のmacOSバージョンでXcodeからビルドし、以下の項目を確認してください。

ドラッグにゼロ以外のしきい値を設ける理由

DragGesture では、ジェスチャが成立するまでに必要な最小移動距離を指定できます。例として 8 を設定すると、親ビューがドラッグを始めるまでに、通常のクリックで許容される移動の余地が生まれます。これはアプリ側で決める値であり、Appleが定めたしきい値でも、クリックとドラッグを分ける完全な方針でもありません。距離をゼロにしたドラッグでは、この区別がなくなります。AppleのDragGestureに関するドキュメント

一時的な移動と確定した移動を分ける理由

ジェスチャの認識中、updating(_:body:) は現在の移動量を liveTranslation に書き込みます。Appleの説明では、ジェスチャが終了またはキャンセルされると、このジェスチャ状態はリセットされます。この例では onEnded のときだけ最終オフセットを確定するため、キャンセル時には未確定のプレビューが破棄され、古い一時的な移動量が残りません。Appleのupdatingに関するドキュメント

表示位置は、確定済みのオフセットに現在の移動量を加えたものです。Appleの定義では、translation はドラッグ開始位置からの累積移動量です。そのため、変更のたびに確定済みオフセットへ加算すると、移動量を重複して数えることになります。たとえば確定済みの水平方向のオフセットが 40 で、現在の移動量が 12 の場合、表示されるオフセットは 52 です。ドラッグ終了時には 52 が1回だけ確定されます。Appleのtranslationに関するドキュメント

名前付きの座標空間は、移動するパネルを囲む固定ステージに属します。これにより、明示した基準領域に対して移動量を計算できます。子ビューのボタンは、パネルの位置を独自に保持しません。

選択状態を親が管理する理由

親は selectedID を保持し、各子ビューに現在の選択状態と選択用クロージャを渡します。これは、状態を必要とする最も上位のビューに置き、値やバインディングを階層の下へ渡すというAppleの指針に沿っています。AppleのStateに関するドキュメント

クロージャは現在のアイテムをキャプチャし、親の状態更新を要求します。Swiftでは、クロージャが周囲のコンテキストから値をキャプチャできます。このコールバックを実装するために、AppKitのイベントをコピーしたり転送したりする必要はありません。The Swift Programming Language: Closures

ヒット領域とジェスチャマスクを理解する

contentShape(Rectangle()) はヒットテストのシェイプを定義します。ボタンのラベルでは、パディングとフレームの後に置くことで、意図する長方形のラベル領域を明示します。親ビューでは、パネルの操作領域を定義します。ジェスチャの優先順位を設定したり、2つのジェスチャの協調を要求したりするものではありません。Appleのcontent shapeに関するドキュメント

ジェスチャ修飾子に渡すマスクは、どのジェスチャを有効なままにするかを決めます。意味を取り違えやすい選択肢に注意してください。subviews は、追加された親ジェスチャを無効にするという意味であり、子ビューと自動的に協調させるという意味ではありません。AppleのGestureMaskに関するドキュメント

マスク ドキュメント上の効果 この例での適性
.all 追加されたジェスチャと、ビューおよびそのサブビューの他のジェスチャを有効にする 共有認識を始める際の基本設定
.gesture 追加されたジェスチャを有効にし、サブビュー階層のジェスチャを無効にする 子ビューのジェスチャを維持する要件と矛盾する
.subviews サブビューのジェスチャを有効にし、追加されたジェスチャを無効にする 親のドラッグが無効になる
.none 追加されたジェスチャを含め、サブビュー階層のジェスチャを無効にする 必要な操作が無効になる

親のドラッグコールバックが呼ばれない場合に、highPriorityGesture を一般的な修正方法として扱わないでください。ドキュメント上の目的は、アタッチしたジェスチャの優先順位を高くすることです。Appleの例では、親のジェスチャが子ビューのタップより優先されます。その優先動作が意図した仕様である場合に使ってください。Appleの高優先度ジェスチャに関するドキュメント

アクティベーションと分けたい場合は、専用のドラッグハンドルを使う

パネルのボタンに共有のドラッグ領域を持たせない場合は、同じ状態と移動量の計算を維持したまま、pan をヘッダーだけにアタッチします。親パネルから .simultaneousGesture(pan, including: .all) を削除します。

完全なプログラム内の既存のヘッダー式を、次のコード片に置き換えます。ここでは、すでに定義済みのプロパティ pan を使います。他に変更が必要な箇所はありません。

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

ヘッダーとアイテムボタンの領域が分かれ、祖先の親ビューからパネルのドラッグがアタッチされることもなくなります。アイテム上で操作を始めるとボタンとして動作し、ヘッダー上で始めるとパネルを移動できます。共有認識では動作が曖昧になる場合に、この設計が役立ちます。

子ビューからアクションを始める場合は、標準のボタンを使ってください。Appleの説明では、Button はアクションとラベルの組み合わせであり、合成した色付きアイテムで使ったように、カスタムのラベルビューも使用できます。自前のポインターハンドラーに置き換えると、コントロールがすでに提供している動作を再実装する必要があります。AppleのButtonに関するドキュメント

フレームワークの境界ではAppKitのイベント所有権を保つ

パネルにAppKitビューが含まれる場合は、SwiftUIのジェスチャ合成とは別に、そのビューのヒットテストを調べてください。AppKitの hitTest(_:) は、条件に一致する最も深い子孫を返します。これにより、ウィンドウはマウスダウンイベントの受け取り先を判断できます。入力位置は、ビューのスーパービュー座標で表されます。AppleのAppKitヒットテストに関するドキュメント

ヒットテストで親ビューを返すと、最初のイベント対象が変わります。これは、子ビューと親ビューの両方でジェスチャを同時認識させる方法とは異なります。同様に、オーバーレイのヒットテストで nil を返すと、そのオーバーレイは対象から外れます。子ビューの一連の操作を受動的に監視するようになるわけではありません。

AppKitの説明では、デフォルトの mouseDown(with:) 実装はメッセージを次のレスポンダーへ渡します。これはレスポンダーチェーンの動作であり、コントロールがイベントを処理した後に、すべての祖先がそれぞれイベントのコピーを受け取るという保証ではありません。コントロールのアクティベーションとパネルの移動は、自分で管理する明示的なコールバックや認識器を通じて結び付けてください。Appleのmouse-downに関するドキュメント

2つのAppKitジェスチャ認識器を自分で管理し、同時認識が必要な場合は、AppKitの gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) を使います。Appleの説明では、true を返すと両方の認識器が同時に認識できます。false を返しても、もう一方の認識器のデリゲートが許可する場合に、同時認識が排除される保証はありません。この方針は、意図した認識器のペアに限定してください。Appleの認識器デリゲートに関するドキュメント

小さな動作確認表で操作を検証する

アプリがサポートするmacOSの各リリースで、実際に使うボタンスタイルとビュー階層をテストしてください。次の項目を確認すると、認識の問題と移動計算の問題を切り分け、共有領域が意図した仕様に合っているか確認できます。

操作 確認すること
ドラッグせずに各アイテムをクリックする 選択状態が変わり、パネルは動かない
しきい値を超えてヘッダーをドラッグする パネルがポインターに追従し、終了位置を保つ
共有領域のバージョンでアイテムからドラッグする 親パネルが動くことを確認し、ボタンもアクティベートされるか調べて、それが許容されるか確認する
続けて2回ドラッグする 2回目は最初に確定した位置から始まり、中間の移動量が累積されない
ドラッグ中に操作を中断する 一時的な移動量が消去され、未完了の移動は確定されない
ハンドルを使う方法を試す アイテムが個別にアクティベートされ、ヘッダーから移動を始められる
キーボード操作でアイテムボタンをアクティベートする ポインターでドラッグしなくても選択できる

パネルの空白部分はドラッグできるのにアイテム領域ではできない場合は、子コントロールとジェスチャ合成を調べてください。クリックが反応しない場合は、ジェスチャマスク、優先順位、ヒット領域を確認します。パネルが加速したり跳ねたりする場合は、認識器を追加する前に、移動量の累積と選択した座標空間を調べてください。

この例では移動範囲をあえて制限していないため、パネルは表示中のステージの外まで移動できます。Reset panel ボタンで位置を元に戻せます。完成したワークスペースで移動範囲を制限する場合は、ステージとパネルの寸法に基づいて、明示的な位置制約を追加してください。同じ状態管理を保ちます。子ビューはアクションを要求し、親ビューは移動を管理し、ジェスチャ領域を重ねるかどうかは操作仕様に従って決めます。

参考資料