在 Mac 应用内进行拖放时,源声明支持的操作,目标选择其中一项,模型负责实现结果。在 macOS 26 或更高版本的 SwiftUI 中配置两端,并且仅在移动完成后移除原项。

本文将构建一个简单的桌面交互:将一个虚构的圆拖到“在此复制”或“在此移动”区域。所有内容都保留在内存中。示例明确展示了操作、项目标识以及源端清理,然后将相同职责映射到 AppKit。

源和目标必须就什么达成一致?

可传输的载荷描述在视图间传递的内容。复制或移动则描述原项会发生什么。这是两个独立的决定。Apple 的 SwiftUI 文档指出,拖动默认支持复制;启用移动后会增加另一种支持的操作。随后由目标选择如何接收该项目。DragConfiguration

使用以下三项职责来设计交互:

职责 复制 移动
源端能力 提供复制 提供移动
目标端提交 插入独立项目 接收现有项目
源端完成处理 保留原项 移动完成后移除原项

在这个圆形示例中,独立副本会获得新的标识符。移动则保留原标识符,同时改变其位置。这是应用层的设计选择,符合 Swift 对可标识实体拥有稳定标识的要求;标识的范围和持续时间由应用决定。Identifiable

实际影响是,返回移动操作并不会自动将元素从一个数组移到另一个数组。你仍需在目标端插入项目,并在源端移除项目。Apple 特别演示了如何观察已完成的拖动操作,以告知模型何时移除源项目。onDragSessionUpdated(_:)

Mac 应用应使用哪些 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(_:)

我们的两个目标有意采用固定含义。“在此复制”接受复制;“在此移动”接受移动。目标在其指定操作不可用时返回禁止。这样无需按键修饰符,结果也能保持可预测。

构建一个完整的内存内复制和移动示例

创建一个 macOS SwiftUI 应用,将部署目标设为 macOS 26 或更高版本,然后用下面这个单文件替换生成的应用和内容视图声明。示例传输一个内置字符串,其中包含项目标识符,因此不需要自定义可传输类型或自定义类型声明。Apple 文档记载,拖动源可以使用可传输载荷,放置目标可以使用字符串载荷。draggable(_:)、dropDestination(for:isEnabled:action:)

这是一个完整的最小应用,适用于只有一个打开窗口和一个源项目的情况。我已根据检索到的文档核对其 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)
        }
    }
}

源配置会在应用内提供复制和移动操作。它使用用于应用外操作的 macOS 初始化方法,禁用应用外的操作。目标还会检查 localSession;Apple 文档指出,从其他位置发起的拖动不会提供此属性。这些设置让示例专注于自身窗口内存中的项目。DragConfiguration.OperationsWithinApp、init(allowCopy:allowMove:allowDelete:)、DropSession

放置动作会先验证确切的令牌,再插入任何内容。复制会追加一个新标识符。移动会追加原标识符,并记录此目标已接收该标识符。随后,只有当会话以移动操作结束且存在该接收记录时,源端才会移除其显示的原项。这个接收记录是此有限演示中的应用层保护措施,并非 SwiftUI 事务功能。

Apple 当前的拖动阶段声明包含 initial、active、ending、ended 和 dataTransferCompleted。示例使用文档记录的 ended(DropOperation) 情况来处理操作结果。它不会在悬停期间或较早的结束阶段移除源项目。DragSession.Phase

明确所有权和手势边界

这个字符串是查找令牌,而不是圆形项目的完整模型。目标会在此视图拥有的源状态中解析它。对于多个文档或窗口,应将此本地查找替换为明确的模型所有者,并使用以实际拖动会话和项目为键的接收记录。仅凭拖动是在应用内发起的,无法确定项目归哪个文档所有。

对于多个可拖动项目,请使用稳定的模型标识符,并在可用时从会话中获取被拖动项目的标识符。Apple 为可标识载荷或单独提供的标识符提供了 draggedItemIDs(for:)。完成事件到达时,不要移除恰好处于选中状态的项目;选中状态和被拖动项目标识是不同的输入。DragSession

让 SwiftUI 的 draggable(_:) 修饰符安装拖动交互。载荷构造过程不要移除模型项目。Apple 文档指出,该修饰符会添加适当的手势,并提醒不要将导出闭包分派到主线程执行。draggable(_:)

在 AppKit 中如何实现相同的一致性?

如果部署目标或现有视图架构需要显式的拖动生命周期,请使用 AppKit。其源协议通过 draggingSession(_:sourceOperationMaskFor:) 声明允许的操作。上下文可用于区分应用内和应用外的目标。对于上述交互,在应用内提供复制和移动,在应用外则不提供任何操作。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

同时验证操作和模型

对于此示例,请使用以下预期状态清单。这些是根据实现得出的验收条件,并非声称已执行的测试结果:

操作 原项 复制目标 移动目标
在“在此复制”上放置一次 保留 获得一个新标识符 不变
再复制一次 保留 再获得一个新标识符 不变
放置到“在此移动” 移动完成后消失 不变 接收原标识符
取消,或在两个目标之外松开 保留 不变 不变

行为不符时,请按顺序检查各个阶段:源端能力、目标端操作、目标端插入以及源端完成处理。移动后原项重复,可能意味着缺少源端清理。项目被移除两次,可能意味着存在多个模型所有者。意外发生复制,可能意味着源端未提供移动操作,或目标端选择了复制。

请明确最终职责:协商出的操作描述传输方式,而模型负责实现项目的位置和标识。提供你支持的操作,在目标端选择允许的操作,并在文档规定的生命周期边界各执行一次模型变更。

来源