在 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
同时验证操作和模型
对于此示例,请使用以下预期状态清单。这些是根据实现得出的验收条件,并非声称已执行的测试结果:
| 操作 | 原项 | 复制目标 | 移动目标 |
|---|---|---|---|
| 在“在此复制”上放置一次 | 保留 | 获得一个新标识符 | 不变 |
| 再复制一次 | 保留 | 再获得一个新标识符 | 不变 |
| 放置到“在此移动” | 移动完成后消失 | 不变 | 接收原标识符 |
| 取消,或在两个目标之外松开 | 保留 | 不变 | 不变 |
行为不符时,请按顺序检查各个阶段:源端能力、目标端操作、目标端插入以及源端完成处理。移动后原项重复,可能意味着缺少源端清理。项目被移除两次,可能意味着存在多个模型所有者。意外发生复制,可能意味着源端未提供移动操作,或目标端选择了复制。
请明确最终职责:协商出的操作描述传输方式,而模型负责实现项目的位置和标识。提供你支持的操作,在目标端选择允许的操作,并在文档规定的生命周期边界各执行一次模型变更。
来源
DragConfiguration — Apple
Identifiable — Apple
onDragSessionUpdated(_:) — Apple
dragConfiguration(_:) — Apple
dropConfiguration(_:) — Apple
draggable(_:) — Apple
DropSession — Apple
DragSession.Phase — Apple
DragSession — Apple
NSDraggingDestination — Apple
draggingEntered(_:) — Apple
performDragOperation(_:) — Apple
dropUpdated(info:) — Apple
NSDraggingSource — Apple