若要讓合成卡片在原生 macOS 拖曳期間保持完整,請為外層容器設定單一拖曳來源,並將完整卡片呈現為預覽。若需要明確控制預覽的初始影格與指標偏移量,請使用 AppKit。
本指南說明如何在自己的 Mac app 中,於兩個面板之間拖曳一張記憶體中的卡片。實際目標是在維持手勢處理與模型更新之明確歸屬的同時,保留卡片的形狀、文字、間距與識別資訊。
拖曳的哪個部分需要變更?
Apple 將傳輸的資料與拖曳期間顯示的視覺呈現分開處理。SwiftUI 的 draggable(_:preview:) 會在拖曳開始時產生預覽視圖的影像。文件指出,預覽會置中顯示於來源視圖上方。它不會傳輸來源視圖本身。請參閱 Apple 的 draggable(_:preview:) 文件。
使用這份判斷指南,找出你實際需要控制的項目:
| 預期行為 | 建議的起點 |
|---|---|
| 讓卡片標籤、形狀與背景保持在一起 | 在最外層使用單一拖曳來源,並提供一個完整的自訂預覽 |
| 調整提起過程中的外框 | contentShape(.dragPreview, shape) |
| 變更卡片上可啟動拖曳的區域 | contentShape(.interaction, shape) |
| 將卡片放入另一個面板 | 在目的地驗證酬載並更新模型 |
| 明確設定拖曳影像的初始矩形與指標位置關係 | 使用由 AppKit 管理、採用 NSDraggingItem 的工作階段 |
| 在畫布中持續移動元素,且不使用原生放置目的地 | 使用本機 DragGesture 互動 |
兩種內容形狀的差異很重要:Apple 文件指出,.dragPreview 會影響預覽形狀,而 .interaction 則控制點擊測試。單獨變更預覽外框,不會擴大可啟動拖曳的區域。請參閱 ContentShapeKinds.dragPreview。
本機 DragGesture 會透過手勢序列回報拖曳動作。若任務是更新畫布位置,請選擇這種方式。對於本文所述的面板間原生放置任務,請讓 draggable 安裝拖曳手勢。請參閱 Apple 的 DragGesture 參考資料與拖曳來源指南。
為整張合成卡片建立一個預覽
將拖曳修飾器放在涵蓋所有應一同移動內容的最小容器上。若卡片由圓形、兩行文字與圓角背景組成,該容器就是整張卡片。若分別在子元素上設定拖曳來源,就會代表不同的互動。
來源與預覽共用同一個呈現視圖。為該視圖指定明確尺寸與自己的背景,不要依賴周圍面板繼承的樣式。這是根據預覽呈現方式得出的設計建議:讓呈現視圖自成一體,更容易使兩種外觀相符。
完整的 SwiftUI 範例
建立部署目標為 macOS 13 或更新版本的 macOS SwiftUI app。將產生的 app 進入點與內容視圖替換為這個單一檔案,並確保目標中恰好只有一個 @main 宣告。此處使用的自訂預覽修飾器與目的地多載,文件都指出自 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 會提供移動中的預覽。目的地只接受一個相符的標記,並變更唯一的位置值。回傳 true 表示放置成功;回傳 false 則會拒絕放置,這符合目的地動作的文件契約。
這是一個供 macOS app 目標使用的完整範例。由於研究環境沒有 Swift 工具鏈或 macOS 執行環境,因此此處未進行編譯或執行。
將視覺連貫性與模型歸屬分開處理
在此範例中,原始卡片會持續顯示,直到已接受的放置變更 isOnRight。若在面板外放開,模型不會變更。若放回原本已佔用的面板,則會再次指定目前的位置。若標記不符或項目數量不只一個,系統會在狀態變更前拒絕放置。
這種做法可避免在拖曳開始時移除卡片,並在操作取消後還得重新建立卡片。預覽負責維持視覺連貫性;目的地負責變更位置。對於較大的看板,請將相同模式套用至穩定的項目識別碼與單一權威集合。
Swift 的值語意有助於說明,傳遞酬載不會自動移動原始模型。結構與字串具有值語意;指派或傳遞它們,不會在你的 app 集合中自動移動項目。模型更新必須明確表達該項目移動。請參閱結構與類別。
也要區分 app 層級的移動與原生拖曳操作。此範例會在接受放置後變更共用看板狀態;它不會設定原生移動協商,也不保證顯示移動游標。
Apple 文件指出,macOS 26 與更新版本提供 DragConfiguration,用來宣告支援的操作;也提供 onDragSessionUpdated(_:),用來觀察已執行的操作。目的地會選擇要執行的操作。若你在原生移動後實作來源端移除,請與目的地處理協調,避免同一個共用項目被移除兩次。請參閱 DragConfiguration 與 onDragSessionUpdated(_:)。
何時應由 AppKit 管理拖曳工作階段?
若驗收條件要求拖曳影像明確定位,例如提起卡片時讓指標保持在卡片內的同一位置,請使用 AppKit。SwiftUI 文件中的自訂預覽修飾器會接收酬載與預覽視圖,並說明預覽會置中顯示。AppKit 則可直接存取拖曳項目的影格。
使用 AppKit 實作時,請依序進行:
- 讓來源
NSView接收實際的滑鼠按下事件,並追蹤互動是否轉為拖曳。 - 將完整合成卡片繪製成一張
NSImage,並使用其貼板寫入器建立一個NSDraggingItem。 - 使用
setDraggingFrame(_:contents:)設定影像與初始矩形。 - 在發起操作的視圖座標系中表達該矩形,接著使用發起拖曳的滑鼠按下事件與你的
NSDraggingSource呼叫beginDraggingSession(with:event:source:)。 - 讓目的地驗證並提交放置。使用來源的完成回呼清理工作階段。
Apple 指出,發起事件的滑鼠位置會決定拖曳圖示的偏移量,而且每個項目的初始影格必須使用來源視圖的座標。卡片矩形與事件位置必須使用一致的座標系。請參閱 beginDraggingSession(with:event:source:)。
setDraggingFrame(_:contents:) 是文件所述的便利方法,適用於以單一影像表示的拖曳項目。合成卡片可以包含多個繪製元素,同時仍以單一影像表示。請參閱 setDraggingFrame(_:contents:)。
維持事件與工作階段的界線
為卡片的互動區域選擇單一拖曳發起方式。依照實作原則,避免在同一區域的有效 SwiftUI draggable 來源上,再疊加一個會啟動 AppKit 工作階段的來源。兩者都會試圖管理同一個互動。不要以偽造滑鼠事件的方式來補救手勢歸屬不明的問題。
你可以使用 NSViewRepresentable 嵌入 AppKit 來源。將原生事件處理留在其 NSView 中,並透過繫結或協調器傳遞已接受的模型變更。SwiftUI 會控制被表示視圖的版面配置;Apple 特別指出,不要直接設定該受管理視圖的 frame 或 bounds。拖曳項目的影像矩形則是另一回事。請參閱 NSViewRepresentable。
不要保留 NSDraggingItem 並在之後修改它。Apple 文件指出其生命週期有限:建立工作階段後,請透過工作階段列舉來變更拖曳項目,並只在每個列舉回呼期間使用所列舉的項目。拖曳經過目的地時,目的地端的變更也可能覆寫來源端的影像變更。請參閱 NSDraggingItem 與 enumerateDraggingItems(options:for:classes:searchOptions:using:)。
如何診斷外觀仍不正確的預覽?
在問題出現的階段檢查原因:
| 症狀 | 首要檢查項目 |
|---|---|
| 只有圓形或文字移動 | 在完整卡片容器上設定單一拖曳來源。 |
| 預覽尺寸與卡片不同 | 共用同一個呈現視圖,並指定明確尺寸。 |
| 提起時的外框與移動影像不同 | 分別檢查 .dragPreview 與自訂預覽閉包。 |
| 空白區域意外啟動拖曳 | 檢查 .interaction 與來源容器的界限。 |
| AppKit 影像在提起時跳動 | 檢查發起拖曳的滑鼠按下事件與來源視圖座標。 |
| 後續的 AppKit 影像變更沒有作用 | 在工作階段列舉期間執行變更。 |
| 取消後卡片消失 | 將模型變更移至已接受的放置處理,並檢查清理流程。 |
請分別驗證取消、成功放置、放回原始面板,以及拒絕酬載。重要的不變條件是,整個過程始終由單一模型位置作為權威來源。先使用完整的 SwiftUI 預覽,只有在所需影像位置必須明確設定影格與事件控制時,才採用由 AppKit 管理的來源。
來源
draggable(_:preview:) — Apple 開發者文件。
將視圖轉為拖曳來源 — Apple 開發者文件。
dragPreview — Apple 開發者文件。
DragGesture — Apple 開發者文件。
dropDestination(for:action:isTargeted:) — Apple 開發者文件。
結構與類別 — Swift.org。
DragConfiguration — Apple 開發者文件。
onDragSessionUpdated(_:) — Apple 開發者文件。
beginDraggingSession(with:event:source:) — Apple 開發者文件。
setDraggingFrame(_:contents:) — Apple 開發者文件。
NSViewRepresentable — Apple 開發者文件。
NSDraggingItem — Apple 開發者文件。
enumerateDraggingItems(options:for:classes:searchOptions:using:) — Apple 開發者文件。