要让合成卡片在原生 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 实现时,请按以下顺序操作:

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

来源