重なり合うドロップ領域がある Mac のキャンバスでは、安定した祖先ビュー 1 つにネイティブのドロップ処理を設定し、現在のモデル状態から論理的なドロップ先を解決します。ドロップを確定する際に適格性を再確認してください。拒否されたコールバックが別のビューへの引き継ぎになるとは、ドキュメントに記載されていません。

この記事では、合成した図形ラベルと重なり合う 2 つのパネルを含むアプリで、この操作を実装します。具体的なタスクは、上のパネルがアイテムを受け付けなくなったとき、ドラッグ中に適格性が変わった場合も含めて、下のパネルがドロップを受け取れるようにすることです。以下のルーティング方針は、ドキュメントに記載されたコールバックをもとにしたアプリ設計です。

状態を変更しても、ドラッグが別のターゲットに確実に引き継がれないのはなぜですか?

まず、次の 3 つの問いを分けて考えます。どのネイティブビューがドラッグを受け取るのか、アプリがどの論理パネルを適格とみなすのか、最終的なアイテムをどのモデルが受け取るのか、です。状態変更によって 2 つ目の問いへの答えは変わることがありますが、1 つ目の問いへの新たな答えが確立されるわけではありません。

Apple は、SwiftUI の validateDrop(info:) を、互換性のあるドロップが対象に入ったときの検証として説明しています。dropUpdated(info:) は、検証済みのドロップが修飾されたビュー内を移動したときの更新として説明されています。どちらの説明にも、所有ビューと無関係な状態が変化するたびに、新たな対象検索を行うという保証はありません。SwiftUI の検証 と ドロップの更新。

AppKit の境界はより明確です。同時に draggingUpdated(_:) メッセージの一連の流れを受け取る宛先は 1 つだけです。重なり合う 2 つのビューが有効な宛先である場合、画像が解放されるかドラッグで外へ出るまで、最前面のビューがそれらのメッセージを受け取ります。これは AppKit のルーティングについての説明であり、SwiftUI の兄弟ビュー間における一般的な優先順位の契約を定めるものではありません。AppKit の宛先更新。

古い SwiftUI のドロップアクションから false を返すと、ドロップが失敗したことを報告します。Apple は、その戻り値を背後にある宛先へ再試行する要求とは説明していません。同様に、dropUpdated(info:) から nil を返すと、最後に有効だった提案が維持されるか、既定でコピーになります。「そのまま通す」という意味ではありません。クロージャベースのドロップ処理 と 提案の動作。

実際には、フォールバックを明示的なアプリのルールにする必要があります。1 つのキャンバスでは、ネイティブの受け取り先を 1 つにして、重なり合う子ビューの間でネイティブな引き継ぎが起きることに頼らず、複数の論理パネルから選択できます。

修飾子を付ける前に、所有関係とフォールバックを定義する

この例では、オレンジのパネルが青のパネルの背後にあります。オレンジはサンプルラベルを常に受け付けます。青は blueEnabled が true の間だけ受け付けます。意図する動作は次のとおりです。

ポインタの位置 青がドロップを受け付けるか 論理的な宛先
オレンジのみの領域 どちらの値でも オレンジ
重なり合う領域 はい 青
重なり合う領域 いいえ オレンジ
青のみの領域 いいえ なし
どちらのパネルの外側 どちらの値でも なし

この表は実装時の判断材料です。特に、青を無効にしても、その矩形内のすべての場所がオレンジの宛先になるわけではありません。ポインタがオレンジの矩形内にもある場合に限り、オレンジがアイテムを受け取ります。

ルーティングを制御する状態は、表示パネルより上位に置きます。Apple は、状態値を必要とする最上位のビューに置き、子ビューと共有することを推奨しています。この例では、1 つの親が、適格性、領域、受信したアイテムを含むモデルを所有します。子の矩形は外観を担い、それぞれが独立したドロップハンドラーを持つことはありません。SwiftUI の状態所有権。

ドロップアクションの実行時に、そのモデルを読み取ります。ドラッグの開始時に適格性の Boolean 値や宛先 ID を保存し、後でそれを確定情報として扱うのは避けてください。Swift のクロージャは、作成時のコンテキストから値や参照を保持できます。また、所有者を置き換えた後も、保持された参照が古いモデルを指し続けることがあります。コールバックが実際にどのオブジェクトをキャプチャしているか確認してください。Swift のクロージャキャプチャ。

所有者を置き換える場合は、別途方針を決めます。新しい所有者に対して解決するか、前の所有者に属する処理を拒否するかのどちらかです。拒否する設計としては、所有者にリビジョン番号を付け、置き換え時に値を増やし、遅延処理を確定する前にキャプチャしたリビジョンと比較する方法が有効です。一時的な「現在ハイライト中」の値で所有権を決めないでください。

安定した SwiftUI ドロップ面を作る

次の完全な例は、macOS 26 をデプロイメントターゲットとし、dropDestination(for:isEnabled:action:) を含む SDK を使う macOS SwiftUI アプリを必要とします。Apple は、このオーバーロードが Transferable 型、有効状態のフラグ、デコード済みアイテムと DropSession を受け取るアクションを指定できると説明しています。宛先は、修飾されたビューのサイズと位置を占めます。現在のドロップ先 API。

SwiftUI を使う新しい macOS App プロジェクトを作成します。生成されたアプリ宣言を、以下の 2 つのコードブロックで置き換え、1 つの Swift ファイルに続けて配置します。2 つを合わせて 1 つのプログラムになります。メモリ内の文字列と矩形だけを使います。ソースラベルはドロップ後も残るため、この例はコピーとして動作します。

モデルと決定論的なルーティング

モデルは、選択した視覚的な優先順位を表すため、先に青を調べます。描画とルーティングの両方で同じ矩形を使います。確定メソッドは、現在の適格性を使って宛先を再計算し、追加する前にサンプル一式がすべて有効か検証します。

import SwiftUI
import Combine

@MainActor
final class CanvasModel: ObservableObject {
    enum Zone: String {
        case orange = "Orange"
        case blue = "Blue"
    }

    let orangeRect = CGRect(x: 30, y: 30, width: 230, height: 170)
    let blueRect = CGRect(x: 150, y: 100, width: 230, height: 170)
    let samples = ["Circle", "Square"]

    @Published var blueEnabled = true
    @Published private(set) var orangeItems: [String] = []
    @Published private(set) var blueItems: [String] = []
    @Published private(set) var message = "Drag a sample onto a panel."

    func recipient(at point: CGPoint) -> Zone? {
        if blueEnabled && blueRect.contains(point) {
            return .blue
        }
        if orangeRect.contains(point) {
            return .orange
        }
        return nil
    }

    func receive(_ items: [String], at point: CGPoint, isLocal: Bool) {
        guard isLocal,
              !items.isEmpty,
              items.allSatisfy({ samples.contains($0) }) else {
            message = "No items added: use this window's sample labels."
            return
        }
        guard let zone = recipient(at: point) else {
            message = "No items added: this location has no eligible panel."
            return
        }
        switch zone {
        case .orange:
            orangeItems.append(contentsOf: items)
        case .blue:
            blueItems.append(contentsOf: items)
        }
        message = "Added \(items.joined(separator: ", ")) to \(zone.rawValue)."
    }
}

アプリとドロップ受け取り先

親の矩形が唯一のドロップ修飾子を所有します。色付きのパネルは、競合する宛先として登録されません。DropSession は宛先のローカル座標空間での位置と、アプリ内で開始されたドラッグを識別する localSession を提供します。モデルはサンプル値も確認します。ドロップセッション情報。

@main
struct DropRoutingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CanvasDemo()
        }
    }
}

@MainActor
struct CanvasDemo: View {
    @StateObject private var model = CanvasModel()
    @State private var scheduledChange: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                ForEach(model.samples, id: \.self) { item in
                    Text(item)
                        .padding(10)
                        .background(Color.gray.opacity(0.15))
                        .draggable(item)
                }
            }

            Toggle("Blue accepts drops", isOn: $model.blueEnabled)

            Button("Toggle blue in 3 seconds") {
                scheduledChange?.cancel()
                scheduledChange = Task { @MainActor in
                    do {
                        try await Task.sleep(for: .seconds(3))
                    } catch {
                        return
                    }
                    model.blueEnabled.toggle()
                }
            }

            ZStack(alignment: .topLeading) {
                Rectangle().fill(Color.gray.opacity(0.08))
                panel("Orange", rect: model.orangeRect,
                      color: .orange, opacity: 0.55)
                panel("Blue", rect: model.blueRect,
                      color: .blue,
                      opacity: model.blueEnabled ? 0.55 : 0.15)
            }
            .frame(width: 420, height: 300)
            .dropDestination(for: String.self, isEnabled: true) {
                items, session in
                model.receive(items, at: session.location,
                              isLocal: session.localSession != nil)
            }

            Text(model.message)
            Text("Orange: \(model.orangeItems.joined(separator: ", "))")
            Text("Blue: \(model.blueItems.joined(separator: ", "))")
        }
        .padding(20)
        .onDisappear {
            scheduledChange?.cancel()
        }
    }

    private func panel(_ title: String, rect: CGRect,
                       color: Color, opacity: Double) -> some View {
        Rectangle()
            .fill(color.opacity(opacity))
            .overlay(Text(title))
            .frame(width: rect.width, height: rect.height)
            .position(x: rect.midX, y: rect.midY)
    }
}

SwiftUI の draggable(_:) は、ユーザー入力によってドラッグを開始するために必要なジェスチャーを設定します。遅延ボタンは、この例の適格性状態だけを変更します。ドラッグセッションを開始、再開、転送するものではありません。SwiftUI のドラッグ元の動作。

Toggle blue in 3 seconds を押してから、Circle を重なり合う領域にドラッグし、青が薄くなるまでその場に保持します。重なり合う領域内で解放すると、意図された宛先はオレンジです。青が無効な状態で、重なり合わない青の領域内で解放すると、どちらのアイテム配列も変わらず、ステータスに理由が表示されます。

この受け取り先は、ルーティング方針全体を適用できるよう、キャンバス全体で有効なままです。そのため、この例では位置ごとに異なるネイティブの拒否カーソルやホバーのハイライトは表示されません。ステータスは解放後にアプリ側の受け入れ結果を報告します。製品でそのフィードバックが必要な場合は、提案とホバーの処理を別途追加してください。

コードは取得した API 宣言と照合して確認しました。利用できる環境には Swift ツールチェーンも macOS ランタイムもないため、ここではコンパイルも実行もしていません。

ホバー更新と古いデプロイメントターゲットを別々に扱う

新しいオーバーロードが使えないシステムもサポートする場合は、同じモデルと領域の方針を保ったまま、利用可能な受け取り API を選びます。古い dropDestination(for:action:isTargeted:) は、デコード済みアイテムとローカル座標を渡します。Boolean の戻り値は成功したかどうかを報告します。isTargeted クロージャは、この設計ではキャンバス全体となるネイティブ宛先領域への進入と退出を表します。ポインタの下にある論理パネルを識別するものではありません。古いドロップ先の契約。

明示的な検証と操作の提案には、onDrop(of:delegate:) で DropDelegate を設定します。初期の適格性には validateDrop(info:)、現在の提案には dropUpdated(info:)、最終的な検証と取り込みには performDrop(info:) を使います。.forbidden を返すと、その位置で許可されない操作を表します。別のネイティブ受け取り先への引き継ぎが保証されるわけではありません。デリゲートベースの宛先 と ドロップ操作。

プロバイダーを使う際には、重要な読み込みの境界があります。DropInfo.itemProviders(for:) が有効なのは performDrop の間だけです。このメソッドが戻る前に、プロバイダーの内容の読み込みを開始します。完了は後から届くことがあるため、モデルを変更する前に、宛先の識別情報、リビジョン、適格性が引き続き有効かを再確認します。結果を黙って破棄せず、読み込みの拒否や失敗を報告してください。プロバイダーへのアクセス と ドロップ完了の要件。

ホバー表示では、一時的なセッション情報を受信済みアイテムとは別に保存します。新しい DropSession のドキュメントでは、フェーズの変化を監視する方法として onDropSessionUpdated(_:) が案内されています。モデル状態が変わることで新たなポインタ更新が起きると仮定しないでください。有効なホバー位置をすでに持っている場合は、適格性が変わったときにも論理的なハイライトを再計算します。最終的な受け入れ時には、引き続きルーティング関数を呼び出します。ドロップセッションのライフサイクル。

キャンバスで AppKit を使うべきなのはどんなときですか?

ポインタが静止している間に、ドキュメント化された周期的なネイティブ更新が必要な操作では、AppKit の宛先を使います。AppKit の wantsPeriodicDraggingUpdates() がこの動作を制御します。false を返すと、更新はマウスの動きや修飾キーの変更時に限られます。それ以外の場合は、何も変わらなくても周期的に更新されます。これにより、静止したドラッグ中に現在の操作を再評価するための、ドキュメント化された方法が得られます。周期的なドラッグ更新。

対応する設計も、キャンバスに宛先を 1 つ置く形です。受け入れるペーストボード形式を登録し、draggingEntered(_:) と draggingUpdated(_:) で論理パネルを解決し、解放時に受け入れ可否を確認し、performDragOperation(_:) でアイテムを取り込みます。Apple は、1 つの宛先ビュー内に含まれるグラフィックス領域やテキスト領域に応じて操作を調整する方法を具体的に説明しています。AppKit 宛先のライフサイクル と 位置に応じた操作。

NSViewRepresentable を使ってその宛先を埋め込む場合は、updateNSView(_:context:) でモデル参照、適格性の設定、コールバックを更新します。初期作成だけでは、その後の所有者変更を同期できません。Apple は、AppKit ビューを SwiftUI の状態に合わせる場所として更新メソッドを挙げています。AppKit ビューの更新。

ソース側のイベント境界も維持してください。AppKit の beginDraggingSession(with:event:source:) は、ドラッグ対象のアイテムと起点のマウスダウンイベントを使ってセッションを開始します。これは宛先を付け替える API ではありません。既存の SwiftUI ソースで要件を満たせるなら、そのまま使います。ソース側の操作も所有する必要がある場合にだけ、AppKit のソースを使います。AppKit ドラッグの開始。

所有権に影響するタイミングで状態変更を検証する

サポートする Mac のデプロイメントバージョンで、次の手動確認を行います。これにより、単に見た目がうまく変わっただけの状態と、正しくルーティングできている状態を区別できます。

  • 青が有効な状態で重なり合う領域にドロップする。青の配列だけにアイテムが追加される。
  • ドラッグを始める前に青を無効にする。重なり合う領域はオレンジにルーティングされる。
  • 静止したドラッグ中に青を無効にする。最終的な受け入れでは無効状態が使われる。
  • 青だけの領域で青が無効な状態のままドロップする。どちらの配列も変わらず、ステータスに結果の説明が表示される。
  • ドラッグ中に青を再び有効にする。確定時に重なり合う領域が青へルーティングされる。
  • ドラッグをキャンセルする。どちらの配列も変わらない。
  • アプリがキャンバスの所有者を置き換える場合は、ドラッグ中に置き換え、選択した所有者置換ポリシーが機能することを確認する。
  • 実装でプロバイダーを非同期に読み込む場合は、完了前に所有者を変更し、リビジョンの確認と失敗の明示的な報告を検証する。

スクロール、ズーム、サイズ変更が可能なキャンバスでは、各パネルの境界とドロップ位置を共通の座標空間に変換することで、同じ設計を拡張できます。この例では矩形が固定されているため変換は不要です。変換のあるレイアウトに矩形の数値をそのまま移すと、ルーティング方針が壊れます。

安定したネイティブの受け取り先と現在の状態に基づくルーティング関数を使えば、この操作の結果を明示できます。最上位の適格なパネルがアイテムを受け取り、適格な下位パネルが重なり合う領域のフォールバックを受け取り、覆われていない場所ではモデルが変更されません。この方針をホバーフィードバックと最終的な受け入れで共有し、最終的な受け入れ時には独自に再確認してください。

参照元