在 Mac app 內拖放時,來源宣告支援的操作,目的地選擇其中一種,而你的模型負責實作結果。在 macOS 26 或更新版本的 SwiftUI 中,設定兩端,並且只在移動完成後移除原項目。

本文會建立一個小型桌面互動:將一個模擬圓形拖到 Copy here 或 Move here。所有內容都保留在記憶體中。此範例明確呈現操作、項目識別碼及來源清理方式,接著將相同的職責對應到 AppKit。

來源與目的地必須就什麼達成一致?

可傳輸的酬載描述在檢視畫面之間傳遞的內容。複製或移動則描述原項目會如何處理。這是兩個分開的決定。Apple 的 SwiftUI 文件指出,拖移預設支援複製;啟用移動後,會再加入另一種支援的操作。接著由目的地選擇如何接收項目。DragConfiguration

使用以下三項職責來設計互動:

職責 複製 移動
來源能力 提供複製 提供移動
目的地提交 插入獨立項目 接收現有項目
來源完成處理 保留原項目 移動完成後移除原項目

在這個圓形範例中,獨立的複本會取得新的識別碼。移動則保留識別碼,同時變更其位置。這是應用程式的設計選擇,符合 Swift 對可識別實體具有穩定識別性的要求;識別性的範圍和持續時間由應用程式決定。Identifiable

實際上,回傳移動操作並不會將元素移到你的陣列中。你仍須插入目的地並從來源移除項目。Apple 特別示範了如何觀察已完成的拖移操作,讓模型知道何時移除來源項目。onDragSessionUpdated(_:)

Mac app 應使用哪些 SwiftUI API?

此實作使用 macOS 26 或更新版本作為部署目標,並使用包含相關文件所述設定修飾子的 SDK。將 draggable(_:) 加到來源,使用 dragConfiguration(_:) 宣告來源的操作,再使用 dropConfiguration(_:) 選擇目的地的操作。兩個設定修飾子都記載為適用於 macOS 26。dragConfiguration(_:)、dropConfiguration(_:)

目前的 dropDestination(for:isEnabled:action:) 多載會將 DropSession 傳入其動作。該動作回傳 Void。這與較舊的多載不同;舊多載的動作會接收位置並回傳布林值。範例會明確指定 isEnabled:,以清楚表明使用的是哪個多載。dropDestination(for:isEnabled:action:)

在目的地,選擇操作前先檢查 suggestedOperations。若要建立一般偏好移動的目標,若提供移動就選移動,否則若提供複製就選複製,再不然就選禁止操作。Apple 的文件範例採用這種從移動退回複製的做法,並提醒設定閉包會頻繁執行,因此應保持簡潔。dropConfiguration(_:)

我們的兩個目標刻意指定了固定用途。Copy here 接受複製;Move here 接受移動。若目標所指定的操作無法使用,便回傳禁止操作。這樣不必使用鍵盤修飾鍵,結果也能維持可預期。

建立完整的記憶體內複製與移動範例

建立 macOS SwiftUI app,將部署目標設為 macOS 26 或更新版本,然後以以下單一檔案取代自動產生的 app 與內容檢視宣告。此範例傳輸內建字串,其中含有項目識別碼,因此不需要自訂可傳輸型別或自訂型別宣告。Apple 記載了供拖移來源使用的可傳輸酬載,以及供放置目的地使用的字串酬載。draggable(_:)、dropDestination(for:isEnabled:action:)

這是一個完整的精簡 app,適用於只有一個開啟視窗和一個來源項目的情況。我已根據取得的文件檢查其 API 參照;由於此處沒有 Swift 和 macOS SDK,因此無法編譯或執行。

import SwiftUI
import Foundation

@main
struct CopyMoveDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CopyMoveDemo()
        }
    }
}

struct CopyMoveDemo: View {
    @State private var sourceID = UUID()
    @State private var sourcePresent = true
    @State private var copiedIDs: [UUID] = []
    @State private var movedIDs: [UUID] = []
    @State private var moveReceived = false

    private var sourceConfiguration: DragConfiguration {
        DragConfiguration(
            operationsWithinApp: .init(allowMove: true),
            operationsOutsideApp: .init(
                allowCopy: false,
                allowMove: false,
                allowDelete: false
            )
        )
    }

    var body: some View {
        VStack(spacing: 24) {
            Text("Drag the circle to a target")
                .font(.headline)

            if sourcePresent {
                Circle()
                    .fill(.blue)
                    .frame(width: 90, height: 90)
                    .overlay(Text("Circle").foregroundStyle(.white))
                    .draggable(sourceID.uuidString)
                    .dragConfiguration(sourceConfiguration)
                    .onDragSessionUpdated { session in
                        if session.phase == .initial {
                            moveReceived = false
                        }
                        if session.phase == .ended(.move), moveReceived {
                            sourcePresent = false
                        }
                        if case .ended = session.phase {
                            moveReceived = false
                        }
                    }
            } else {
                Text("Original moved")
                    .frame(width: 90, height: 90)
            }

            HStack(alignment: .top, spacing: 20) {
                target(moving: false)
                target(moving: true)
            }
        }
        .padding(24)
        .frame(minWidth: 520, minHeight: 320)
    }

    private func target(moving: Bool) -> some View {
        let operation: DropOperation = moving ? .move : .copy
        let ids = moving ? movedIDs : copiedIDs

        return VStack(spacing: 10) {
            Text(moving ? "Move here" : "Copy here")
                .font(.headline)
            ForEach(ids, id: \.self) { id in
                Text("Circle \(id.uuidString.prefix(4))")
            }
            Spacer(minLength: 20)
        }
        .frame(width: 210, height: 150)
        .background(.blue.opacity(0.08))
        .contentShape(Rectangle())
        .dropDestination(for: String.self, isEnabled: true) {
            tokens, session in
            guard session.localSession != nil,
                  sourcePresent,
                  tokens == [sourceID.uuidString] else {
                return
            }
            if moving {
                guard !movedIDs.contains(sourceID) else { return }
                movedIDs.append(sourceID)
                moveReceived = true
            } else {
                copiedIDs.append(UUID())
            }
        }
        .dropConfiguration { session in
            guard sourcePresent,
                  session.localSession != nil,
                  session.itemsCount == 1,
                  session.suggestedOperations.contains(operation) else {
                return DropConfiguration(operation: .forbidden)
            }
            return DropConfiguration(operation: operation)
        }
    }
}

來源設定會在 app 內提供複製與移動操作,並使用 macOS 用於 app 外操作的初始設定式,停用 app 外的操作。目的地也會檢查 localSession;Apple 記載,若拖移來自其他地方,此值便不存在。這些設定讓範例專注於自身視窗中、位於記憶體內的項目。DragConfiguration.OperationsWithinApp、init(allowCopy:allowMove:allowDelete:)、DropSession

放置動作會先驗證確切的權杖,再插入任何內容。複製會附加新的識別碼。移動則會附加原識別碼,並記錄此目的地已接收該項目。只有在工作階段以移動結束且存在接收紀錄時,來源才會移除其顯示中的原項目。這項接收紀錄是此精簡示範在應用程式層級採用的保護措施,並非 SwiftUI 的交易功能。

Apple 目前的拖移階段宣告包含 initial、active、ending、ended 和 dataTransferCompleted。此範例使用文件所述的 ended(DropOperation) 案例來處理操作結果。它不會在游標懸停時或較早的結束階段移除來源項目。DragSession.Phase

明確界定所有權與手勢邊界

這個字串是查找權杖,不是圓形的完整模型。目的地會在此檢視畫面所擁有的來源狀態中解析它。若有多份文件或多個視窗,請將這個本機查找改為明確的模型擁有者,並以實際拖移工作階段和項目作為接收紀錄的鍵。拖移來自 app 內部,本身並不能確定項目由哪份文件擁有。

若有多個可拖移項目,請使用穩定的模型識別碼,並在可用時從工作階段取得被拖移的識別碼。Apple 為可識別酬載或另行提供的識別碼提供 draggedItemIDs(for:)。完成通知到達時,避免移除當下恰好被選取的項目;選取狀態與被拖移項目的識別碼是不同輸入。DragSession

讓 SwiftUI 的 draggable(_:) 修飾子安裝拖移互動。酬載建構過程中不要移除模型項目。Apple 記載此修飾子會加入適當的手勢,並提醒不要將匯出閉包派送到主演員。draggable(_:)

AppKit 中相同的協定如何運作?

當你的部署目標或現有檢視架構需要明確管理拖移生命週期時,請使用 AppKit。其來源協定會透過 draggingSession(_:sourceOperationMaskFor:) 宣告允許的操作。此情境可讓你區分 app 內外的目的地。對於上述互動,在 app 內提供複製與移動;在 app 外則不提供任何操作。draggingSession(_:sourceOperationMaskFor:)

目的地會註冊相關的貼上板型別,接著在 draggingEntered(_:) 中選擇操作,並視需要在 draggingUpdated(_:) 中更新選擇。Apple 要求目的地考量來源操作與其本身支援的操作及資料型別之間的交集。回傳一種操作;若沒有可接受的交集,則不回傳任何操作。NSDraggingDestination、draggingEntered(_:)

準備成功後,在 performDragOperation(_:) 中執行插入。只有在目的地接受資料時才回傳 true。來源會透過 draggingSession(_:endedAt:operation:) 接收已完成的操作;將該結果與模型的所有權規則一併使用,在移動時完成來源端的移除。performDragOperation(_:)、draggingSession(_:endedAt:operation:)

若要在 SwiftUI 介面中嵌入 AppKit 拖移區域,請讓該區域負責 AppKit 互動。beginDraggingSession(with:event:source:) 會接收啟動拖移的滑鼠按下事件,而拖移項目的框架使用檢視畫面的座標系統。請保留事件與座標的對應關係,不要用後續 SwiftUI 手勢值偽造事件。beginDraggingSession(with:event:source:)

較舊的 SwiftUI 目的地程式碼可以從 DropDelegate.dropUpdated(info:) 回傳 DropProposal(operation: .move)。這表示目的地的提議,但無法取代來源能力設定、已完成操作的觀察,或模型變更。若無法使用較新的 SwiftUI 來源 API,且你需要明確控制兩端,AppKit 的來源與目的地生命週期便能提供這種方式。dropUpdated(info:)、NSDraggingSource

一併驗證操作與模型

此範例的預期狀態檢查清單如下。這些是根據實作推導出的驗收條件,並非實際測試結果:

動作 原項目 複製目標 移動目標
在 Copy here 放置一次 保留 新增一個識別碼 不變
再複製一次 保留 再新增一個識別碼 不變
放置到 Move here 移動完成後消失 不變 接收原識別碼
取消,或在兩個目標之外放開 保留 不變 不變

若行為不符預期,請依序檢查各階段:來源能力、目的地操作、目的地插入,以及來源完成處理。移動後仍有重複的原項目,表示來源清理可能缺失。項目被移除兩次,表示可能有兩個模型擁有者同時處理。出現非預期的複製,則可能是來源未提供移動,或目的地選擇了複製。

請清楚劃分最後的職責:協商出的操作描述傳輸方式,而你的模型負責實作項目的位置與識別碼。提供你支援的操作,在目的地選擇允許的操作,並在文件記載的生命週期界線各變更一次模型。

來源