要让合成卡片在原生 macOS 拖动期间保持完整,请为其外层容器指定一个拖动源,并将完整卡片渲染为预览。如果需要明确控制预览的初始框架和指针偏移,请使用 AppKit。
本指南介绍如何在你自己的 Mac 应用中的两个面板之间拖动一张内存中的卡片。实际目标是在清晰划分手势处理和模型更新职责的同时,保留卡片的形状、文本、间距和身份。
拖动的哪一部分需要改变?
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 应用。将生成的应用入口和内容视图替换为这个单文件,并确保目标中恰好只有一个 @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 应用目标的完整示例。由于研究环境没有 Swift 工具链或 macOS 运行环境,因此这里没有编译或运行它。
将视觉连续性与模型所有权分开处理
在此示例中,原始卡片会一直显示,直到已接受的放置更改 isOnRight。在面板外松开不会更改模型。放回已占用的面板会再次设置现有位置。状态更改前会拒绝不匹配的令牌或多个项目。
这种行为避免了在拖动开始时移除卡片,然后在操作取消后还得重新创建卡片。预览负责保持视觉连续性;目标位置负责更改位置。对于更大的棋盘,可对稳定的项目标识符和唯一权威集合使用同样的模式。
Swift 的值语义有助于解释为什么传递载荷不会自动移动原始模型。结构体和字符串具有值语义;在应用程序的集合中,赋值或传递它们不会自动重新定位项目。模型更新必须明确表达这种重新定位。参见结构体和类。
还要区分应用程序级别的重新定位和原生拖动操作。此示例会在放置被接受后更改一个共享棋盘状态;它不会配置原生移动协商,也不保证显示移动光标。
对于 macOS 26 及更高版本,Apple 记录了用于声明支持操作的 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 开发者文档。