若要讓合成卡片在原生 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 實作時,請依序進行:

  1. 讓來源 NSView 接收實際的滑鼠按下事件,並追蹤互動是否轉為拖曳。
  2. 將完整合成卡片繪製成一張 NSImage,並使用其貼板寫入器建立一個 NSDraggingItem。
  3. 使用 setDraggingFrame(_:contents:) 設定影像與初始矩形。
  4. 在發起操作的視圖座標系中表達該矩形,接著使用發起拖曳的滑鼠按下事件與你的 NSDraggingSource 呼叫 beginDraggingSession(with:event:source:)。
  5. 讓目的地驗證並提交放置。使用來源的完成回呼清理工作階段。

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 管理的來源。

來源