ネイティブ macOS のドラッグ中に合成カードを崩さずに表示するには、外側のコンテナにドラッグソースを 1 つ設定し、プレビューとしてカード全体を描画します。プレビューの初期フレームとポインタのオフセットを明示的に制御する必要がある場合は、AppKit を使用します。

このガイドでは、自作の Mac アプリ内で、メモリ上のカード 1 枚を 2 つのパネル間でドラッグします。実用上の目標は、ジェスチャ処理とモデル更新の責任を明確に保ちながら、カードの形状、テキスト、余白、識別情報を維持することです。

ドラッグのどの部分を変更する必要がありますか?

Apple は、転送されるデータと、ドラッグ中に表示される視覚表現を分けています。SwiftUI の draggable(_:preview:) は、ドラッグ開始時にプレビュービューをレンダリングします。ドキュメントによると、プレビューはソースビューの中央に配置されます。ソースビュー自体が転送されるわけではありません。Apple の draggable(_:preview:) ドキュメントを参照してください。

この判断ガイドを使って、必要な制御を特定してください。

実現したい動作 推奨される出発点
カードのラベル、形状、背景をひとまとまりに保つ 外側のドラッグソース 1 つと、カード全体を描画するカスタムプレビュー 1 つ
持ち上げるときのアニメーション中に輪郭を調整する contentShape(.dragPreview, shape)
カードのどの部分からドラッグを開始できるか変更する contentShape(.interaction, shape)
カードを別のパネルに移す ペイロードを検証し、ドロップ先でモデルを更新する
ドラッグ画像の初期矩形とポインタとの位置関係を明示的に設定する NSDraggingItem を使う AppKit 管理のセッション
ネイティブのドロップ先を使わず、キャンバス内で要素を連続的に移動する ローカルな DragGesture インタラクション

2 種類のコンテンツシェイプの違いは重要です。Apple の説明では、.dragPreview はプレビューの形状に影響し、.interaction はヒットテストを制御します。プレビューの輪郭だけを変更しても、ドラッグ開始領域は広がりません。ContentShapeKinds.dragPreviewを参照してください。

ローカルな DragGesture は、ジェスチャシーケンスを通じてドラッグ操作を通知します。キャンバス上の位置を更新する場合は、この方法を選びます。この記事で扱うパネル間のネイティブドロップには、draggable にドラッグジェスチャを設定させます。Apple の DragGesture リファレンスとドラッグソースのガイドを参照してください。

合成カード全体のプレビューを 1 つ作る

一緒に移動させたい要素をすべて含む、最小のコンテナにドラッグ修飾子を付けます。円、テキスト 2 行、角丸の背景で構成するカードなら、そのコンテナはカード全体です。子要素にそれぞれドラッグソースを付けると、別々のインタラクションとして扱われます。

ソースとプレビューで同じプレゼンテーションビューを再利用します。そのビューに明示的なサイズと独自の背景を設定し、周囲のパネルから継承したスタイルに依存させません。これは、レンダリングされたプレビューの動作から導かれる設計上の推奨事項です。自己完結したプレゼンテーションを使うと、ソースとプレビューの見た目を一致させやすくなります。

SwiftUI の完全な例

macOS 13 以降をデプロイメントターゲットにした macOS SwiftUI アプリを作成します。生成されたアプリのエントリポイントとコンテンツビューを、この単一ファイルに置き換えます。ターゲット内の @main 宣言は 1 つだけにしてください。ここで使うカスタムプレビュー修飾子とドロップ先のオーバーロードは、どちらも macOS 13 以降で利用可能とドキュメントに記載されています。draggable(_:preview:)とdropDestination(for:action:isTargeted:)を参照してください。

import Foundation
import SwiftUI

@main
struct IntactDragDemoApp: App {
    var body: some Scene {
        WindowGroup {
            BoardView()
        }
    }
}

private struct CardFace: View {
    var body: some View {
        HStack(spacing: 14) {
            Circle()
                .fill(Color.orange)
                .frame(width: 36, height: 36)

            VStack(alignment: .leading, spacing: 6) {
                Text("Tile A")
                    .font(.headline)
                Text("Circle and label")
                    .font(.caption)
            }
            .foregroundStyle(Color.white)
        }
        .padding(16)
        .frame(width: 220, height: 100, alignment: .leading)
        .background(Color.indigo)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

private struct BoardView: View {
    @State private var token = UUID().uuidString
    @State private var isOnRight = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Drag the card between panels")
                .font(.headline)

            HStack(spacing: 24) {
                panel(right: false)
                panel(right: true)
            }
        }
        .padding(24)
    }

    private func panel(right: Bool) -> some View {
        VStack(spacing: 16) {
            Text(right ? "Right" : "Left")
                .font(.headline)

            ZStack {
                if isOnRight == right {
                    CardFace()
                        .contentShape(
                            .interaction,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .contentShape(
                            .dragPreview,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .draggable(token) {
                            CardFace()
                        }
                } else {
                    Text("Drop here")
                        .foregroundStyle(.secondary)
                }
            }
            .frame(width: 240, height: 120)
        }
        .frame(width: 260, height: 180)
        .background(
            RoundedRectangle(cornerRadius: 20)
                .fill(Color.gray.opacity(0.12))
        )
        .contentShape(Rectangle())
        .dropDestination(for: String.self) { items, _ in
            guard items == [token] else {
                return false
            }
            isOnRight = right
            return true
        }
    }
}

この例では、合成グラフィックと生成したメモリ上の識別子だけを使います。String はすでに Transferable に準拠しているため、独自の転送表現は必要ありません。Apple はその組み込みの準拠をビューをドラッグソースにするで説明しています。

想定される操作は簡単です。Tile A をもう一方のパネルにドラッグして離し、続けて元のパネルに戻します。移動中のプレビューには CardFace 全体が使われます。ドロップ先は、一致するトークンを 1 つだけ受け付け、場所を示す単一の値を変更します。true を返すとドロップ成功となり、false を返すと拒否されます。これはドロップ先アクションのドキュメントにある契約に従っています。

これは macOS アプリのターゲットを対象とした完全な例です。調査環境には Swift ツールチェーンも macOS ランタイムもないため、ここではコンパイルも実行もしていません。

見た目の連続性とモデルの管理責任を分ける

この例では、ドロップが受け付けられて isOnRight が変更されるまで、元のカードが表示されたままです。パネルの外でドラッグを離してもモデルは変更されません。カードがすでにあるパネルにドロップすると、既存の場所が再度割り当てられます。トークンが一致しない場合や複数アイテムの場合は、状態を変更する前に拒否されます。

この動作なら、ドラッグ開始時にカードを取り除き、キャンセル時に再構築する必要がありません。プレビューが見た目の連続性を保ち、ドロップ先が場所の変更を管理します。より大きなボードでは、安定したアイテム識別子と単一の正規コレクションに同じパターンを適用します。

値渡しのペイロードが元のモデルを自動的に移動させない理由は、Swift の値セマンティクスで説明できます。構造体と文字列は値セマンティクスを持つため、代入や受け渡しによって、アプリ内のコレクションにあるアイテムが暗黙に移動することはありません。モデル更新でその移動を表現する必要があります。構造体とクラスを参照してください。

また、アプリケーションレベルの移動とネイティブのドラッグ操作は区別してください。この例では、ドロップ成功後に共有ボード状態を変更します。ネイティブの移動処理を設定したり、移動カーソルを保証したりするものではありません。

macOS 26 以降では、Apple は対応する操作を宣言する DragConfiguration と、実行された操作を監視する onDragSessionUpdated(_:) をドキュメント化しています。ドロップ先は実行する操作を選びます。ネイティブの移動後にソースからアイテムを削除する場合、同じ共有アイテムを二重に削除しないよう、ドロップ先の処理と連携させてください。DragConfigurationとonDragSessionUpdated(_:)を参照してください。

AppKit にドラッグセッションを管理させるのはどんな場合ですか?

カードをつかんだときに、ポインタがカード内の同じ位置にあるように保つなど、ドラッグ画像の位置を明示的に指定する必要がある場合は AppKit を使います。SwiftUI のドキュメントにあるカスタムプレビュー修飾子は、ペイロードとプレビュービューを受け取り、中央に配置されたプレビューについて説明しています。AppKit ではドラッグアイテムのフレームを直接指定できます。

AppKit で実装する場合は、次の順序に従います。

  1. ソースの NSView で実際のマウスダウンイベントを受け取り、操作がドラッグに変わったかどうかを追跡します。
  2. 合成カード全体を 1 つの NSImage に描画し、ペーストボードライターを持つ NSDraggingItem を 1 つ作成します。
  3. setDraggingFrame(_:contents:) で画像と初期矩形を設定します。
  4. その矩形を開始元のビューの座標系で表し、開始時のマウスダウンイベントと NSDraggingSource を使って beginDraggingSession(with:event:source:) を呼び出します。
  5. ドロップ先にドロップを検証して確定させます。セッションの後処理にはソースの完了コールバックを使います。

Apple の仕様では、開始イベントのマウス位置によってドラッグアイコンのオフセットが決まり、各アイテムの初期フレームはソースビューの座標で指定する必要があります。カードの矩形とイベント位置には、一貫した座標系を使ってください。beginDraggingSession(with:event:source:)を参照してください。

setDraggingFrame(_:contents:) は、1 つの画像で表されるドラッグアイテムのための、ドキュメントに記載された簡易メソッドです。合成カードに複数の描画要素が含まれていても、1 つの画像で表せます。setDraggingFrame(_:contents:)を参照してください。

イベントとセッションの境界を保つ

カードの操作領域には、ドラッグの開始元を 1 つだけ選びます。実装上のルールとして、同じ領域で有効な SwiftUI の draggable ソースの上に、AppKit のセッション開始処理を重ねないでください。どちらも同じ操作を管理しようとします。ジェスチャの管理元が不明確だからといって、マウスイベントを作り出して補わないでください。

NSViewRepresentable を使って AppKit のソースを組み込めます。ネイティブのイベント処理はその NSView に置き、受け付けられたモデル変更はバインディングまたはコーディネーターを通じて伝えます。SwiftUI は表現対象のビューのレイアウトを制御します。Apple は、管理対象ビューの frame や bounds を直接設定しないよう明記しています。ドラッグアイテムの画像矩形は別の問題です。NSViewRepresentableを参照してください。

NSDraggingItem を保持して後から変更しないでください。Apple のドキュメントでは、その有効期間は限定されています。セッション作成後にドラッグアイテムを変更するにはセッション列挙を使い、列挙された各アイテムはコールバックの中でのみ使用してください。ドラッグがそのドロップ先の上にある間は、ドロップ先側の変更がソース側の画像変更を上書きすることもあります。NSDraggingItemとenumerateDraggingItems(options:for:classes:searchOptions:using:)を参照してください。

それでもプレビューの見た目がおかしい場合、どう診断しますか?

問題が現れる段階に応じて確認します。

症状 最初に確認すること
円やテキストだけが移動する カード全体のコンテナにドラッグソースを 1 つ設定します。
プレビューのサイズがカードと異なる 同じプレゼンテーションビューと明示的なサイズを再利用します。
つかんだときの輪郭と移動画像が異なる .dragPreview とカスタムプレビュークロージャを別々に確認します。
予期せず空白部分からドラッグが始まる .interaction とソースコンテナの境界を確認します。
AppKit の画像がつかんだときに跳ねる 開始時のマウスダウンイベントとソースビューの座標を確認します。
AppKit の画像を後から変更しても反映されない セッション列挙の中で変更します。
キャンセル後にカードが消える モデル変更をドロップ成功時の処理に移し、後処理を確認します。

キャンセル、ドロップ成功、元のパネルへのドロップ、ペイロードの拒否をそれぞれ検証します。重要な不変条件は、常に 1 つのモデル上の場所が正規の情報源であり続けることです。まずカード全体の SwiftUI プレビューから始め、必要な画像配置に明示的なフレームとイベントの制御が必要な場合にのみ、AppKit 管理のソースを採用します。

参考資料