ドラッグ元のセッション終了コールバックから、薄暗くした図形、アウトライン、ドラッグラベルを復元します。ドロップ先の exit コールバックが示すのは、ポインタがそのターゲットから離れたことだけです。Mac アプリでは、利用可能なら SwiftUI のソースセッション API を使うか、AppKit のソースにドラッグを管理させます。
このガイドで扱うのは、ユーザーがドラッグを終えた後、どのドロップ先も受け入れなかった場合も含めて、合成図形を通常の表示に戻す方法です。この例ではコンテンツをメモリ内に保持し、受け入れたドロップをコピーとして扱うため、フィードバックの復元でソースを削除したり移動したりする必要はありません。
ドロップ先の外でドラッグフィードバックが残るのはなぜですか?
よくある実装では、ドラッグ開始時に isDragging フラグを設定し、performDrop で解除します。これでは、受け入れ先だけが処理できるイベントにクリーンアップを結び付けることになります。ウィンドウ内の何もない場所でボタンを離すと、ソースをリセットする責任を持つドロップ先がありません。
Apple は DropDelegate を、ドロップを受け入れるよう変更されたビューのためのインターフェースと定義しています。その dropExited(info:) メソッドは、検証済みのドラッグがそのビューから離れたことを通知します。ドラッグは別のターゲットへ進むことも、最初のターゲットへ戻ることも、別の場所で終了することもあります。したがって、ターゲットからの退出だけではソースセッションの終了を判断できません。DropDelegate
同じ区別は dropDestination(for:action:isTargeted:) にも当てはまります。isTargeted が表すのは、ポインタがターゲット領域内にあるかどうかです。これはターゲットの境界線や背景に使います。ソースの薄暗さやドラッグラベルは、ソースセッションのコールバックで制御します。dropDestination(for:action:isTargeted:)
文書化されたこれらの境界から、次の設計ルールが導けます。各視覚状態は、その終了を確実に検知できるイベントの所有者に割り当てます。
| フィードバックまたは結果 | 所有者 | リセットまたは確定のタイミング |
|---|---|---|
| 薄暗くしたソース図形 | ドラッグ元 | ソースセッション終了時 |
| ハイライトされたドロップ先 | ドロップ先 | ポインタが離れたとき。ローカルドラッグではソース完了時にも防御的にクリーンアップ |
| 受け入れられた合成アイテム | ドロップ先/モデル | ドロップの検証と成功時 |
| 一時的にエクスポートしたリソース | 転送ライフサイクル | 該当する場合、転送完了時 |
Mac アプリでは、サポートされているどの方法を使うべきですか?
Apple の現行リファレンスでは、onDragSessionUpdated(_:) は macOS 26 以降で利用可能とされています。この API は、子ビュー階層内で最も近いドラッグセッションの更新を監視します。スコープを明確にするため、ソースのドラッグ修飾子のすぐ外側に付けます。このメソッドが実際に宣言されている SDK を使ってください。実行時に利用可能でも、SDK に宣言がなければコンパイルできません。onDragSessionUpdated(_:)
ドキュメントには食い違いがあるため、ここでは必要な範囲だけ対処します。修飾子の例では位置と操作の引数を伴う終了フェーズが示されていますが、フェーズ専用のリファレンスでは ended(DropOperation) と宣言されています。以下の例では、視覚状態の復元にそれらの値が不要なため、関連値を取り出さずに .ended と照合します。操作ごとの動作を追加する場合は、インストール済み SDK の宣言を確認してください。DragSession.Phase.ended(_:)
デプロイメントターゲットや SDK が SwiftUI のコールバックを利用できない場合は、AppKit のドラッグソースで文書化された完了時点を取得できます。Apple は、ドラッグが終了またはキャンセルされたときにソースが draggingSession(_:endedAt:operation:) を受け取ることを明記しています。カスタムソースはセッション自体を開始する必要があります。beginDraggingSession(with:event:source:)
1つのキャンバス内で図形を移動するだけなら、システムのドラッグ&ドロップセッションの代わりにローカルの DragGesture を検討してください。@GestureState はジェスチャーが非アクティブになるとリセットされるため、そのジェスチャーの一時的なフィードバックに適しています。この選択は、ドロップ先に転送可能なコンテンツを渡すシステムドラッグとは分けて考えてください。GestureState
SwiftUI でソース所有のクリーンアップを実装する
onDragSessionUpdated(_:) を公開する SDK を使って macOS SwiftUI アプリを作成し、デプロイメントターゲットを macOS 26 以降に設定して、生成されたアプリとコンテンツビューの宣言を、この完全なプログラムに置き換えます。合成文字列 shape.circle を1つ用意し、受け取り領域も1つ設けます。
ソースは draggable(_:) を使って転送可能なコンテンツを提供します。ドロップ先は一致する文字列を1つだけ受け入れ、それ以外の入力には false を返します。Apple の説明では、ドロップ先の Boolean 戻り値はドロップが成功したかどうかを示します。draggable(_:)、dropDestination(for:action:isTargeted:)
import SwiftUI
@main
struct DragFeedbackDemoApp: App {
var body: some Scene {
WindowGroup {
DragFeedbackDemo()
}
}
}
struct DragFeedbackDemo: View {
private let payload = "shape.circle"
@State private var isDragging = false
@State private var isTargeted = false
@State private var acceptedCopies = 0
var body: some View {
VStack(spacing: 24) {
Text("Drag the circle into the receiving area")
Circle()
.fill(Color.blue)
.frame(width: 72, height: 72)
.opacity(isDragging ? 0.4 : 1)
.draggable(payload)
.onDragSessionUpdated { session in
switch session.phase {
case .initial, .active:
isDragging = true
case .ended, .dataTransferCompleted:
restoreFeedback()
default:
break
}
}
Text(isDragging ? "Dragging" : "Ready")
RoundedRectangle(cornerRadius: 12)
.fill(isTargeted
? Color.blue.opacity(0.2)
: Color.gray.opacity(0.12))
.overlay {
Text("Receiving area")
}
.frame(width: 280, height: 120)
.dropDestination(for: String.self) { items, _ in
guard items.count == 1,
items.first == payload else {
return false
}
acceptedCopies += 1
return true
} isTargeted: { targeted in
isTargeted = targeted
}
Text("Accepted copies: \(acceptedCopies)")
}
.padding(32)
.frame(minWidth: 380, minHeight: 400)
}
private func restoreFeedback() {
isDragging = false
isTargeted = false
}
}
2つのフラグはそれぞれ異なる役割を持ちます。isDragging はソースの不透明度を変えます。isTargeted は受け取り領域の塗りつぶしを変えます。そのため、受け取り領域への進入や退出によって、ドラッグ継続中に誤ってソースが元に戻ることはありません。
フェーズのリファレンスでは、initial はセッション開始直前、active は移動中、ended は完了を表します。この例では ended でフィードバックを復元し、dataTransferCompleted でも同じリセットを安全に繰り返せるようにしています。失敗したドラッグの復元を転送完了だけに依存させることはありません。DragSession.Phase
Apple の説明では、dataTransferCompleted はドラッグされた項目の転送が完了し、一時項目をクリーンアップできる時点です。この例には一時的なエクスポートリソースはありません。大規模な実装では、視覚状態のクリーンアップとリソースのクリーンアップを分け、転送に必要なものを不透明度の復元によって破棄しないようにします。DragSession.Phase.dataTransferCompleted
セッション中、円はビュー階層内に残ります。ドロップが受け入れられると、受け取り先のカウントが増え、受け入れられない場合はコンテンツは変わりません。そのため、並べ替えや移動の意味付けとは切り離して、フィードバックを診断するのに役立つ例です。取得した宣言に照らしてコードを確認しましたが、利用可能な環境が SwiftUI を備えていない Linux のため、コンパイルも実行もしていません。
AppKit の代替方法でイベントの所有権を保つには?
より古い macOS バージョンをサポートする場合は、ドラッグソースの実装を、NSViewRepresentable 経由でホストする NSView に置き換えます。Representable はネイティブビューを作成・更新し、そのコールバックを SwiftUI の状態に橋渡しします。SwiftUI が表現対象ビューのレイアウトを制御するため、表現対象ビューのフレームを自分で設定するのではなく、SwiftUI のサイズ指定修飾子を使います。NSViewRepresentable
代替実装は、ソース所有の1つの流れとして構成します。
- ネイティブソースビューの
mouseDown(with:)で、そのビューに届いた実際のマウスダウンイベントを保持します。 mouseDragged(with:)で、選択したドラッグ開始しきい値を適用し、2つ目のセッションが始まらないようにします。- 合成アイテムの識別子を保持するメモリ内ペーストボードライターから、
NSDraggingItemを作成します。 - 生成した図形画像を渡し、ソースビューの座標系でドラッグフレームを設定します。
- 保持したマウスダウンイベントと、
NSDraggingSourceを実装するオブジェクトを使って、beginDraggingSession(with:event:source:)を呼び出します。 draggingSession(_:willBeginAt:)でソースのフィードバックを表示し、draggingSession(_:endedAt:operation:)では必ず元に戻します。- トラッキング終了時に、保持した入力イベントとセッション情報を消去します。セッション完了まではソースとそのコールバック所有者を存続させます。
これらの詳細は AppKit の明示的な契約に従っています。セッション開始メソッドには開始のきっかけとなるマウスダウンイベントを渡し、ドラッグ項目のフレームにはビューの座標系を使い、ソースがライフサイクル通知を受け取ります。NSDraggingItem は NSPasteboardWriting に準拠するオブジェクトを受け取ります。単一画像用の便利メソッドは、ドラッグフレームと内容を設定します。beginDraggingSession(with:event:source:)、init(pasteboardWriter:)、setDraggingFrame(_:contents:)
このアプリ内の操作では、NSDraggingContext.withinApplication に対して .copy を返し、それ以外のコンテキストには空の操作セットを返します。これは、ローカルコピーのみを提供するという明示的な選択です。AppKit は draggingSession(_:sourceOperationMaskFor:) を通じて、ソースが許可する操作を問い合わせます。draggingSession(_:sourceOperationMaskFor:)
コピーが成功したときだけでなく、どの完了結果でも表示を元に戻します。モデルの変更は、アプリが実際に実装した操作に応じて行います。完了コールバックの位置はスクリーン座標です。SwiftUI のターゲットのローカル境界と直接比較しないでください。draggingSession(_:endedAt:operation:)
既存の SwiftUI .draggable の背後に配置した透明なネイティブビューは、NSDraggingSource に準拠するだけでは、そのセッションのソースにはなりません。セッション開始時に渡されたオブジェクトがソースです。開始と完了の所有者を1つに決め、ネイティブのドラッグ領域を図形が意図する操作範囲に限定します。
マウスアップの監視やプロバイダーの解放を使わないのはなぜですか?
アプリ全体のマウスアップ監視が観測するのは入力イベントの配送であり、ドラッグ完了とは異なる境界です。Apple の説明では、ローカルイベントモニターはネストされたトラッキングループが消費したイベントを受け取りません。この制約があるため、モニターはソースの文書化された完了コールバックの代わりにはなりません。addLocalMonitorForEvents(matching:handler:)
同様に、アイテムプロバイダーの deinit が示すのはオブジェクトのライフタイムです。Swift のデイニシャライザーはインスタンスが解放される直前に呼ばれます。この契約から、ドラッグがいつ終了したかは判断できません。プロバイダーの破棄を完了シグナルとして使うと、UI 状態がドラッグのライフサイクルではなく、参照の所有関係に結び付いてしまいます。Deinitialization
システムドラッグと並行して別の DragGesture を追加する場合も、同じ概念上の問題があります。その状態はそのジェスチャーに属します。ローカルジェスチャーが操作を所有する場合は @GestureState を使い、システムドラッグが所有する場合はドラッグ元のセッションコールバックを使います。GestureState
完了パターン表で修正を確認する
合成円を使って、各経路を個別に確認します。以下は実装に期待される動作であり、テスト結果の報告ではありません。
| 操作 | 期待されるソースフィードバック | 期待されるドロップ先の結果 |
|---|---|---|
| 受け取り領域内に円をドロップする | 通常の表示に戻る | カウントが1増える |
| ウィンドウ内の何もない場所でボタンを離す | 通常の表示に戻る | カウントは変わらない |
| ドラッグ中に受け取り領域から離れる | 薄暗いまま | ターゲットのハイライトが消える |
| 再度入り、有効なドロップを完了する | 完了時に通常の表示に戻る | カウントが1増える |
| 受け入れ先がないままアプリのウィンドウ外でボタンを離す | 通常の表示に戻る | カウントは変わらない |
| アクティブなドラッグをキャンセルする | 通常の表示に戻る | カウントは変わらない |
| 失敗するドラッグを何度か繰り返す | 各セッションで正常に復元される | 受け入れ済みコピーは累積しない |
ソースが薄暗いままなら、まず完了オブザーバーが実際のドラッグソースを囲んでいることと、ソースがビュー階層に残っていることを確認します。ターゲットのハイライトだけが残る場合は、ターゲットの進入・退出処理と共有クリーンアップを調べます。拒否されたドロップで受け入れ数が変わる場合は、視覚状態の復元とは分けて、検証処理とモデルの変更を調べます。
長く使える修正の要点は、完了の所有権を明確にすることです。ソースはセッション全体のフィードバックを復元し、ドロップ先は自身のホバー状態と受け入れたコンテンツを処理し、転送のクリーンアップは文書化されたライフサイクルに従います。これらの責任を分ければ、すべてのドロップ先の外にドロップしても、ドロップ先のコールバックを待ち続ける視覚状態は残りません。
出典
onDragSessionUpdated(_:) — Apple
DragSession.Phase — Apple
DragSession.Phase.ended(_:) — Apple
draggable(_:) — Apple
DropDelegate — Apple
GestureState — Apple
NSViewRepresentable — Apple
init(pasteboardWriter:) — Apple
setDraggingFrame(_:contents:) — Apple
Deinitialization — Swift.org