在 Mac 应用的两个面板之间复制合成图形时,建议从 SwiftUI 类型化拖放开始。如果现有提供器接口或较早版本的 macOS 部署目标要求使用项目提供器,再选择它们。在选择任一 API 之前,先定义负载、接收规则和模型所有者。

定义成功放置的含义

这里的读者任务很明确:让用户将合成圆形或方形从调色板拖到同一 Mac 应用中的一个集合里。目标位置会创建一个新项目;调色板保持不变。所有内容都保存在内存中,关闭窗口后集合就会丢弃。

Apple 建议 Swift 拖放使用 transferable 项目。其文档也支持基于提供器的修饰符,因此具体选择取决于部署要求和需要集成的接口。拖放 — Apple。

实现视图之前,先写下传输契约:

契约问题 本示例中的决定
跨边界传递什么? 描述图形种类的带版本值
谁可以接收此表示? 仅源应用
成功放置会做什么? 向集合追加一个新标识的项目
谁负责执行变更? 目标视图的集合状态
取消或拒绝时会怎样? 模型不变
放置位置代表什么? 在这里没有含义;已接受的项目按顺序追加

这些属于应用设计决策。拖动预览展示负载,而目标位置决定负载如何改变模型。将这两项职责分开,就能在不重写集合规则的情况下更换传输方式。

选择最能表达契约的精简 API

情况 起点 原因
SwiftUI 源视图和目标视图,macOS 13 或更高版本 Transferable、.draggable 和类型化的 .dropDestination 直接表达可接收的模型类型
现有 NSItemProvider 集成 .onDrag 和 .onDrop 保留显式的表示注册和加载
部署目标早于 macOS 13 检查该目标支持哪些提供器修饰符 类型化基线要求 macOS 13;下文讨论使用的提供器重载要求 macOS 11
现有 AppKit 视图负责鼠标交互 AppKit 拖动 API 保留视图的事件处理和拖动会话生命周期

Apple 说明了 macOS 13 起提供的 Transferable 和原始类型化放置目标。基于提供器的 onDrop(of:isTargeted:perform:) 重载接受 UTType 值数组,最低支持版本为 macOS 11。请检查具体重载,不要假设所有拖放 API 的最低版本都相同。Transferable、dropDestination(for:action:isTargeted:)、onDrop(of:isTargeted:perform:)。

两种方法都支持仅限本应用的可见性。对于类型化表示,应用 .visibility(.ownProcess);Apple 将该级别定义为仅在源应用内可见。因此,仅仅为了让传输留在应用内,无需选择项目提供器。visibility(_:)、ownProcess。

构建类型化复制交互

这个完整示例由新 macOS SwiftUI 应用目标中的两个 Swift 文件组成。将部署目标设为 macOS 13 或更高版本,并在此基线中使用 Swift 5 语言模式。用第二个文件替换生成的应用入口点;只保留一个 @main 声明。代码已经检查过,但此处无法编译,因为可用的 shell 没有 Swift 工具链。

声明负载及其内容类型

在目标的 Info 属性列表中添加导出的类型声明。在 Xcode 的属性列表编辑器中,将 UTExportedTypeDeclarations 创建为数组,并在其中放入一个字典。将该字典的 UTTypeIdentifier 设为 com.example.shape-transfer,UTTypeDescription 设为 Synthetic Shape Transfer,并将 UTTypeConformsTo 设为包含 public.data 的数组。这个标识符仅作示例;实际应用应使用唯一标识符。

Swift 声明和属性列表标识符必须一致。Apple 要求为自定义统一类型添加相应的属性列表条目,并说明了应用定义格式的导出声明。此示例传输的是数据而非文件,因此不添加文件扩展名。为应用定义文件和数据类型。

创建 ShapePayload.swift:

import Foundation
import CoreTransferable
import UniformTypeIdentifiers

extension UTType {
    static let shapeTransfer = UTType(
        exportedAs: "com.example.shape-transfer",
        conformingTo: .data
    )
}

enum ShapeKind: String, Codable, Sendable {
    case circle = "Circle"
    case square = "Square"
}

struct ShapePayload: Codable, Sendable, Transferable {
    let version: Int
    let kind: ShapeKind

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .shapeTransfer)
            .visibility(.ownProcess)
    }
}

struct PlacedShape: Identifiable {
    let id = UUID()
    let kind: ShapeKind
}

CodableRepresentation 负责在负载与可传输数据之间进行转换。负载包含一个值,而不是视图或可变集合引用。其显式 Sendable 遵循 Swift 规则:可发送结构体所存储的成员本身也必须可发送。Transferable、Sendable。

这里有意只使用一种表示。文本代理会增加另一种使用项目的方式,但对这个契约没有帮助。Apple 介绍了表示顺序和回退格式;只有接收方确实需要时才添加它们。将视图变为拖动源。

接收负载并提交一次集合更新

创建 ShapeTransferApp.swift:

import SwiftUI

@main
struct ShapeTransferApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeTransferView()
        }
    }
}

@MainActor
struct ShapeTransferView: View {
    @State private var placed: [PlacedShape] = []
    @State private var targeted = false
    @State private var message = "Drag a shape into Collection."

    var body: some View {
        HStack(alignment: .top, spacing: 24) {
            VStack(spacing: 16) {
                Text("Palette").font(.headline)
                paletteItem(.circle)
                paletteItem(.square)
            }
            .frame(width: 160)

            VStack(alignment: .leading, spacing: 12) {
                Text("Collection").font(.headline)
                Text(message)
                ForEach(placed) { item in
                    Label(item.kind.rawValue,
                          systemImage: symbol(for: item.kind))
                }
                Spacer(minLength: 20)
            }
            .padding()
            .frame(width: 300, height: 320, alignment: .topLeading)
            .background(targeted ? Color.blue.opacity(0.15)
                                 : Color.gray.opacity(0.08))
            .contentShape(Rectangle())
            .dropDestination(
                for: ShapePayload.self,
                action: { payloads, _ in
                    guard !payloads.isEmpty,
                          payloads.allSatisfy({ $0.version == 1 })
                    else {
                        message = "Drop rejected: unsupported payload."
                        return false
                    }
                    let additions = payloads.map {
                        PlacedShape(kind: $0.kind)
                    }
                    placed.append(contentsOf: additions)
                    message = "Added \(additions.count) shape(s)."
                    return true
                },
                isTargeted: { targeted = $0 }
            )
        }
        .padding(24)
    }

    private func paletteItem(_ kind: ShapeKind) -> some View {
        Label(kind.rawValue, systemImage: symbol(for: kind))
            .padding()
            .background(Color.gray.opacity(0.12))
            .draggable(ShapePayload(version: 1, kind: kind))
    }

    private func symbol(for kind: ShapeKind) -> String {
        switch kind {
        case .circle: return "circle.fill"
        case .square: return "square.fill"
        }
    }
}

显式的 action: 和 isTargeted: 标签会选择原始类型化目标重载。Apple 将其布尔返回值定义为成功或失败,并将位置参数定义为目标视图坐标空间中的位置。这里有意不使用该位置,因为集合只会追加图形,不会按位置放置。dropDestination(for:action:isTargeted:)。

接收规则会先检查收到的所有负载,再追加任何内容。不支持的版本会导致整批已接收内容被拒绝;每个有效负载都会获得一个新的目标标识。因此,再次从调色板拖动同一项目会创建另一个副本。此规则针对传递给闭包的值;解码失败发生在这些值成为 ShapePayload 之前。

目标状态只会改变呈现效果,不会插入项目,也不会从源位置移除任何内容。这种职责划分也让取消操作有了明确结果:没有被接受的放置时,集合保持不变。

集成要求使用项目提供器时再采用它们

提供器实现可以传递相同的版本和图形种类。保持契约不变,并按以下步骤替换传输方式:

  1. 在 .onDrag 闭包中创建 NSItemProvider,并使用 .ownProcess 可见性,在同一个自定义类型标识符下注册数据表示。
  2. 在加载处理程序中捕获不可变负载值或准备好的字节。对该值进行编码,并通过表示完成处理程序返回数据或错误。
  3. 配置 .onDrop 以接收自定义类型。
  4. 在放置操作中,立即使用 loadDataRepresentation(forTypeIdentifier:completionHandler:) 开始加载每个已接收的提供器。
  5. 解码返回的数据,验证版本并收集结果。只有在所需结果全部可用后,才在主 actor 上执行最终集合变更。

Apple 说明了提供器的显式数据注册和仅限本应用的可见性。其加载方法是异步的,并会返回一个进度对象。类型标识符匹配只表示可以尝试加载;并不保证加载和解码一定成功。registerDataRepresentation(forTypeIdentifier:visibility:loadHandler:)、NSItemProviderRepresentationVisibility.ownProcess、loadDataRepresentation(forTypeIdentifier:completionHandler:)。

有两个时间边界需要注意。首先,Apple 指出,提供器加载必须在 .onDrop 操作期间开始,但可以稍后完成。在其他 actor 上通过稍后的任务启动首次加载调用,不符合这一文档要求。其次,Apple 警告不要在表示处理程序中将导出工作派发到主 actor 或主线程,因为这可能导致挂起。导出工作应独立于 UI 变更;导入完成后再更新集合状态。onDrop(of:isTargeted:perform:)、onDrag(_:)。

对于异步提供器实现,应区分接受加载任务和完成模型事务。使用带索引的结果保留提供器顺序,并报告加载或解码失败;如果契约要求所有项目都成功,则任何失败都应使集合保持不变。不要通过过滤操作悄悄丢弃失败项目。这些实现规则源于示例的批次接收策略。

保留 AppKit 事件所有权和移动语义

如果现有 AppKit 视图已经负责鼠标交互,就让它继续在其中启动会话。Apple 的 beginDraggingSession(with:event:source:) 接收发起拖动的鼠标按下事件、坐标采用该视图坐标系的拖动项目,以及一个符合 NSDraggingSource 的源对象。源回调涵盖会话开始、移动以及完成或取消。beginDraggingSession(with:event:source:)。

AppKit 表示边界也很明确:NSDraggingItem(pasteboardWriter:) 要求提供一个 NSPasteboardWriting 对象。SwiftUI 提供器回调和 AppKit 剪贴板写入对象是不同的接口。将自定义 AppKit 视图桥接到 SwiftUI 时,应由 AppKit 视图继续负责其事件和会话;由共享模型负责已接收的集合变更。init(pasteboardWriter:)。

此示例执行的是复制。如果任务改为在集合之间移动现有项目,就添加稳定的项目标识符,并明确指定唯一的移除责任方。共享存储可以只迁移一次项目;或者目标位置先插入项目,再由源位置在确认移动完成后移除。混用这两种策略可能导致项目被移除两次。

Apple 当前文档包含 macOS 26 的 DragConfiguration、基于会话的类型化放置重载以及拖动会话观察功能。源位置提出支持的操作,目标位置选择操作,而会话完成回调让源位置能够响应结果。采用此路径前,请检查各个符号的 SDK 可用性。DragConfiguration、dropDestination(for:isEnabled:action:)、onDragSessionUpdated(_:)。

不要在指针进入目标位置或预览出现时移除源项目。这些事件都早于接收操作。对于应用内移动,一个实用的不变量是:事务完成后,每个逻辑项目只属于一个集合。

通过可观察结果验证契约

在 Mac 上按以下顺序检查实现:

  • 将 Circle 拖入 Collection:目标集合增加一个圆形,调色板仍保留原项目。
  • 重复拖动:目标集合增加第二个具有独立标识的项目。
  • 将 Square 拖到目标区域之外并释放:集合不变。
  • 暂时将源负载版本改为 2:目标报告拒绝且不添加任何内容;之后恢复为 1。
  • 对于提供器实现,故意返回编码或加载错误:显示失败,并且不插入部分批次。
  • 对于移动实现,取消拖动并确认原项目仍属于原来的集合。

对于这个双面板复制任务,应从类型化 SwiftUI 开始,除非有具体的兼容性或集成要求需要使用提供器或 AppKit。需要长期保持的是这份契约:明确的值跨越边界,目标位置验证该值,然后由一个模型所有者提交已接受的变更。

来源