从零读懂 SwiftUI 新拖放 API:用 Apple 的纸牌示例理解排序、多项拖拽与移动规则

拖动一张卡片,让旁边的卡片自动让位;一次搬走一叠卡片;松手时,只有符合规则的位置才接收。

交互示意动画,非官方示例实机录屏。

这些看似相近的交互,实际上包含三个不同的问题:内容怎样重新排序,一次拖走哪些数据,目标是否允许这次操作。

Apple 在 WWDC26 的《Code-along: Build powerful drag and drop in SwiftUI》中,用一款 Solitaire 纸牌游戏把这三个问题串了起来。

本文参考官方视频、代码、下载工程和 API 文档,按"先理解概念,再读排序,最后读数据转移"的顺序拆解。读完后,你应该能回答:

  • reorderable 和 reorderContainer 为什么要一起使用?
  • ReorderDifference 给的是新数组,还是移动指令?
  • dragContainer 怎样决定一次带走哪些项目?
  • DragConfiguration(allowMove: true) 为什么不会自动帮你搬数据库记录?
  • dropConfiguration 与 dropDestination 分别应该做什么?
  • 为什么相同的 CardValue 类型既可能表示数据,也可能表示 ID?

阅读与核对说明:核对日期为 2026-10-20。讲座为 WWDC26 Session 271,介绍面向 2027 系统版本的新能力。下载工程的 App Target 为 macOS 27.0,使用 SwiftUI 与 SwiftData。

一、先找到官方工程

官方入口:

长期收藏建议使用示例文档入口,下载地址可能更新。

工程主线很清楚:

text 复制代码
SwiftUI-Reorderable/
├── Game/
│   ├── CardValue.swift       牌面值与 Transferable 表示
│   ├── Card.swift            SwiftData 中的一张牌及其位置
│   └── Game.swift            移动、插入、验证规则
└── Views/
    ├── GameView.swift        整个牌桌、排序容器与落点配置
    ├── PileView.swift        某一列牌
    ├── RemainderView.swift   剩余牌堆和当前可拖出的牌
    ├── DestinationView.swift 按花色收集牌的目标区
    └── CardView.swift        单张牌的显示

第一遍不必读完布局实现。先看 CardValue、GameView 和 Game,就能把"拖了什么、放到哪里、数据怎样变"连起来。

二、先分清哪些是新 API,以及"新"是哪个版本

不能把这场讲座里的所有类型都统一写成"iOS 27 才出现"。

本次逐项核对的官方符号文档标注如下。表格聚焦 iOS、iPadOS、macOS 和 visionOS,不是所有平台的完整支持清单:

符号 iOS / iPadOS macOS visionOS
reorderable() 27.0 27.0 27.0
本文使用的多集合 reorderContainer 重载 27.0 27.0 27.0
ReorderDifference 27.0 27.0 27.0
dragContainer(for:in:_:) 27.0 26.0 27.0
dragConfiguration(_:) 27.0 26.0 27.0
dropConfiguration(_:) 27.0 26.0 27.0
DragConfiguration / DropConfiguration 类型 26.0 26.0 26.0
DropSession 类型 26.0 26.0 26.0
接收 DropSession 的 dropDestination 重载 26.0 26.0 26.0
dragPreviewsFormation(_:) 本次文档未列出 26.0 本次文档未列出
dropPreviewsFormation(_:) 本次文档未列出 26.0 本次文档未列出

这里有两个容易忽略的区别:

  1. 配置类型与使用它的修饰符是不同符号,可能有不同的可用版本。
  2. 视频介绍的平台范围与当前符号页面不完全一致。视频说多项拖拽相关修饰符扩展到 iOS、iPadOS、visionOS 27,但本次取得的两个预览队形页面只标 macOS 26。

因此,不能仅凭视频中的一段 macOS 示例,断言同一段预览配置已获得所有移动平台文档确认。跨平台接入时应再核对目标 SDK 的声明与 availability;"文档未列出"也不等于本文证实了永久不支持。

基础的 .draggable(...) 和较早的 .dropDestination(for:action:isTargeted:) 在 iOS 16 时期就已存在。本文后面使用的"回调接收 DropSession"写法,应与老的"接收位置 CGPoint、返回 Bool"重载分开阅读。

三、Transferable:先让系统知道怎样传递一张牌

对应 Game/CardValue.swift。官方节选:

swift 复制代码
nonisolated struct CardValue: Hashable, Sendable, Codable {
    var rank: Rank
    var suit: Suit
}

extension CardValue: Identifiable {
    var id: Self { self }
}

extension CardValue: Transferable {
    nonisolated static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .cardValue)
    }
}

Rank 是点数,Suit 是花色,定义见原文件。

逐个翻译:

  • Codable:这个值可以编码和解码。
  • CodableRepresentation:告诉系统使用可编码表示来传输。
  • .cardValue:示例自定义的 UTType,标识"这是一种牌面数据"。
  • Identifiable:系统能稳定地认出是哪张牌。
  • Sendable:类型满足相应的并发传递要求。

.cardValue 是类型标识,不是图片名,也不是数据库表名。对方 App 只有支持兼容的表示,才能理解这些内容;声明 Transferable 不等于所有 App 都会接收纸牌。

为什么 CardValue 本身可以当 ID?

因为这个示例是一副标准牌,同一牌局里"点数 + 花色"可以识别某张牌,所以 id 直接返回自身。

这就解释了后面的类型:

swift 复制代码
ReorderDifference<CardValue, Card.Group>

第一个泛型参数表达的是 ItemID 。只是恰好 CardValue.ID == CardValue,看上去才像直接传了一张牌。

如果做双副牌游戏,或者允许同名同图的卡片重复出现,就需要独立的稳定标识,例如 UUID,不能继续认为相同牌面就是同一个项目。

四、reorderable:声明"哪些内容可以重新排序"

视频 3:40 的 Preview 先从四张牌开始。官方节选:

swift 复制代码
HStack {
    ForEach(cards) { card in
        CardFaceView(card: card)
    }
    .reorderable()
}
.reorderContainer(for: CardValue.self) { difference in
    cards.apply(difference: difference)
}

这里两行修饰符分工不同:

  • .reorderable() 放在 ForEach 上,标记这批动态内容参与排序。
  • .reorderContainer(...) 放在外层布局上,规定排序作用域并接收结果。

reorderable() 定义在 DynamicViewContent 上。不要把它理解为任意单张 CardFaceView 都能随便添加的通用 View 修饰符。

拖动过程中,SwiftUI 负责抬起、占位和其他视图让位。真正松手后,系统把差异交给闭包,App 再更新数据源。

视觉上已经让位,不代表数组已经保存为新顺序。

五、reorderContainer:参数分别是什么意思?

多集合版本的声明可以这样读,以下为简化展示:

swift 复制代码
reorderContainer(
    for: Item.Type,
    in: CollectionID.Type,
    isEnabled: Bool = true,
    move: (ReorderDifference<Item.ID, CollectionID>) -> Void
)
参数 含义 示例
for 参与排序的数据项类型 CardValue.self
in 用来识别各集合的类型 Card.Group.self
isEnabled 当前是否允许排序,默认 true 保存过程中可以关闭
move 操作结束时接收差异的回调 调用业务方法更新位置

CardValue.self 表示类型本身,不是某张牌,也不是闭包中的 self 对象引用。

单集合时可以省略 in,使用单集合便利重载。数据没有采用 Identifiable 时,还有通过 itemID KeyPath 显式指定标识的重载。

系统基于稳定 ID 描述移动,而不只是返回拖动开始时的数组下标。这有助于在拖动期间数据发生变化时重新定位内容,但仍需要 App 处理记录被删除、目标不存在或业务状态变化。

六、ReorderDifference:拿到的究竟是什么?

可以把它理解为一张"搬运单":

text 复制代码
sources:要移动的项目 ID,带有顺序
    ↓
destination.collectionID:放到哪个集合
    ↓
destination.position:放在某个 ID 前面,或者放到末尾

在示例中,目标位置主要涉及:

  • .before(某个项目ID):插到该项目之前。
  • .end:追加到集合末尾。

这不是标准库的 CollectionDifference,也不是一整个新数组。

cards.apply(difference:) 不是系统自动提供的数组能力

这个方法定义在官方 GameView.swift 底部的 extension Array 中。

它先找来源、移除来源,再根据目标插入。示例实现只读取 difference.sources[0],服务于单张牌 Preview,不能直接当成通用的批量移动算法。

下面是一个单项排序的讲解改写,先在副本上处理,全部确认后再替换原数组:

swift 复制代码
func moveOne<CollectionID: Hashable & Sendable>(
    _ difference: ReorderDifference<CardValue.ID, CollectionID>,
    cards: inout [CardValue]
) {
    guard difference.sources.count == 1,
          let sourceID = difference.sources.first,
          let sourceIndex = cards.firstIndex(of: sourceID) else {
        return
    }

    var updatedCards = cards
    let movedCard = updatedCards.remove(at: sourceIndex)

    switch difference.destination.position {
    case .before(let targetID):
        guard let targetIndex = updatedCards.firstIndex(of: targetID) else {
            return
        }
        updatedCards.insert(movedCard, at: targetIndex)
    case .end:
        updatedCards.append(movedCard)
    }

    cards = updatedCards
}

这里的 CollectionID 使用泛型,因此也能接收单集合便利重载产生的集合标识类型;并不要求调用方一定使用 Card.Group。该方法明确只支持一个来源。多个来源需要按移动顺序整体取出、统一移除、定位目标,再整体插入;跨集合还需要同时更新来源与目标。

七、多列排序:collectionID 不是卡片 ID

纸牌有七列,必须区分"哪张牌"和"哪一列"。

官方在牌桌的公共父布局上声明:

swift 复制代码
.reorderContainer(
    for: CardValue.self,
    in: Card.Group.self
) { difference in
    game.moveCards(difference: difference)
}

每一列内部对应的写法,可以用下面的讲解改写表达:

swift 复制代码
// faceUpIndex 是正面牌起点,pileIndex 是列号,二者含义不同。
ForEach(cards[faceUpIndex...], id: \.value) { card in
    CardView(card: card)
}
.reorderable(collectionID: Card.Group.pile(pileIndex))

这里要求 faceUpIndex 是合法的切片边界,pileIndex 是实际列号。下载源码在 PileView 中用局部 let index = firstFaceUpIndex 遮蔽了同名列号属性,随后的 .pile(index) 也引用该局部值;这是静态阅读发现的潜在集合标识问题。因此本文用两个不同名字解释应有语义,不把原片段作为已验证实现。

Card.Group.pile(0)、.pile(1) 等标识不同集合。共同父容器把这些集合纳入同一排序范围,于是可以从第一列搬到第二列。

text 复制代码
reorderContainer:牌桌上的可排序区域
    ├── collectionID = .pile(0)
    ├── collectionID = .pile(1)
    └── collectionID = .pile(2) ...

不要给每一列各建一个彼此独立的排序容器,再期待系统自然把它们当作同一个跨列排序区域。

不允许拖动背面牌,最简单的方法是什么?

官方把牌分成两个 ForEach:背面牌不加 .reorderable,正面牌才添加。

这个设计很实用:如果内容本来就不应该参与交互,可以先从结构上排除,而不是把所有内容都变成可拖动后,再写一堆拒绝逻辑。

八、dragContainer:一次拖哪些项目,由业务决定

排序默认可以先从单项做起。但纸牌中,拖动某张牌时,需要连同压在它上面的牌一起带走。

视频与下载工程使用:

swift 复制代码
// 官方工程节选:此处按单个起拖 ID 使用。
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}

cardStack(startingAt:) 是业务方法。它找到这张牌,筛选同一组中位置不小于它的牌,再按位置排序并返回牌面值。

不是拖动整列容器,而是一次拖动的 payload 包含多个项目。

单 ID 与 ID 数组:这是两个重载,不是版本冲突

官方文档提供了两个同名重载,区别在 payload 回调的输入:

swift 复制代码
// 单个起拖项目的 ID。
(Item.ID) -> Data

// 一组项目的 ID。
(Array<Item.ID>) -> Data

二者都返回元素为 Item 的集合,所以即使输入只有一个 ID,也可以返回多张牌,实现整叠拖动。视频中的 game.cardStack(startingAt: cardID) 正是这种用法。

数组版本适合结合选择状态组装 payload:起拖项目属于选择集合时,回调收到选择中的 ID;否则只包含起拖项目的 ID。返回空集合可以阻止本次拖动。

如果只是复现纸牌的"从某张牌取整叠",可以显式标注参数类型,明确使用单 ID 重载:

swift 复制代码
.dragContainer(for: CardValue.self) { (cardID: CardValue.ID) in
    game.cardStack(startingAt: cardID)
}

如果采用数组重载并接入多选,应处理整组 ID,明确合并、排序与去重规则,不能只取第一个 ID 就认为实现了多选拖动。

普通 App 的多项选择如何接入?

当前官方文档给出的模式是:

swift 复制代码
// 接口组合示意,需补齐项目类型、选择状态和数据查询。
.draggable(containerItemID: item.id)

子视图标明自己对应的 ID;共同父视图用 dragContainer 生成传输值,再用 dragContainerSelection(...) 提供选择状态。

containerItemID 是查找入口,payload 是真正要传输的值,二者不要混为一谈。in 命名空间参数则可以用来识别拖拽容器;简单场景使用默认值即可。

九、两种 formation:管的是预览外观

官方工程使用:

swift 复制代码
.dragPreviewsFormation(.stack)

以及外层的:

swift 复制代码
.dropPreviewsFormation(.stack)

视频列出 pile、list、stack 等队形。

  • dragPreviewsFormation 控制起拖预览的排列。
  • dropPreviewsFormation 控制经过接收区域时的预览排列。

只设置前者,卡片进入接收区域后,可能又采用目标的默认队形,所以示例还在牌桌外层统一设置后者。

队形不会改变数据库顺序,也不会决定拖走哪些牌。 它只是让一次多项拖动看起来更合适。

平台方面仍需遵守第二节的核对结果:当前两个符号文档只列 macOS 26;本机 Xcode 26.4.1 的 SwiftUI 接口也明确将它们标记为 iOS、visionOS 不可用。这验证了 26 系列 SDK 的限制,不代表已经核对 27 系列 SDK,本文不将它们作为已确认的全平台代码。

十、dragConfiguration:源头表达"我允许移动"

剩余牌堆与七列排序区域是两个不同入口。玩家从剩余牌堆拖出一张牌,不能在目标处复制出第二张相同牌。

官方添加:

swift 复制代码
.dragConfiguration(DragConfiguration(allowMove: true))

这里 allowMove 是"允许 move",不是"强制 move",也不是"禁用 copy"。官方文档说明默认允许复制,最终操作由接收目标决定。

可以把它理解为协商:

text 复制代码
拖出方:我支持哪些操作
    ↓
会话:当前建议的操作集合
    ↓
目标方:我选择接收为 move、copy,或者禁止
    ↓
业务层:真正修改数据

如果源支持移动,目标却选择复制,那么仍然是复制。纸牌的目标只接受合法的 .move,才形成不会复制牌的约定。

十一、dropDestination:接收数据并落实变更

reorderContainer 默认处理容器内部的移动。要接收从剩余牌堆等容器外部拖入的项目,需要继续组合 dropDestination。

官方节选:

swift 复制代码
.dropDestination(for: CardValue.self) { newCards, session in
    if let destination = session.reorderDestination(
        for: CardValue.self,
        in: Card.Group.self
    ) {
        game.insertCards(newCards, to: destination)
    }
}

参数含义:

参数 / 方法 含义
for: CardValue.self 要接收哪一种传输值
newCards 本次收到的值数组
session 本次投放会话,类型为 DropSession
reorderDestination(for:in:) 获取关联排序容器中的目标集合与目标位置,可能为空

官方把这个修饰符声明在 reorderContainer 之后:内部重排交给排序闭包,外部拖入交给投放闭包。

"外部"首先指排序容器外部,并不自动意味着来自其他 App。这个纸牌工程在后面的规则验证中依赖 localSession,并没有提供通用的跨 App 导入流程。

注意新旧 dropDestination 的区别

本节代码的第二个参数是 DropSession。这一会话型 dropDestination 重载从 iOS、macOS、visionOS 26 起提供;但其中用于排序容器的 reorderDestination 属于 27 系列新增能力,不能因为前者支持 26,就认为整段排序代码也支持 26。

旧教程中常见的第二个参数是 CGPoint,通常命名为 location,action 返回 Bool。不要把旧重载里的 location 改名为 session,就以为获得了新会话能力;最终调用哪个重载取决于 SDK 提供的真实方法声明。

十二、dropConfiguration:把落点与游戏规则连接起来

dropDestination 做"收到之后怎么处理",dropConfiguration 做"当前这样放,允许吗"。

官方核心节选:

swift 复制代码
.dropConfiguration { session in
    let alignedX = session.location.x - 0.5 * spacing
    let pile = Int(alignedX / (cardWidth + spacing))
    let destination = ReorderDifference<CardValue, Card.Group>
        .Destination(position: .end, collectionID: .pile(pile))

    let allowed = session.suggestedOperations.contains(.move)
        && game.validateMove(session: session, destination: destination)
    let operation: DropOperation = allowed ? .move : .forbidden

    return DropConfiguration(
        operation: operation,
        destination: destination
    )
}

这段代码完成三件事:

  1. 根据会话位置推算玩家指向哪一列。
  2. 把目标明确设为该列末尾。
  3. 检查来源支持移动,并让游戏规则决定允许还是禁止。

几个新名字逐个解释

符号 含义
session.location 会话在该接收视图中的位置,不应当作屏幕全局坐标
suggestedOperations 本次建议的操作集合,用来检查是否有 .move 等操作
DropOperation 目标选择的投放操作
.forbidden 当前不允许投放,目标不应接收这些牌
DropConfiguration 返回给系统的接收策略
Destination(position:collectionID:) 告诉排序系统插入到哪里

Card.Group.pile(pile) 是某一列的标识;.end 表示列尾,符合这个示例把牌叠到列尾的规则。

配置闭包可能随着指针位置变化被频繁调用。官方文档明确提醒不要做昂贵计算。这里适合读当前状态、命中测试、计算策略;网络请求、数据库写入等副作用应留在真正提交的业务路径。

坐标计算需要边界保护

原示例的横坐标换算较简洁,迁移时至少应核对:

  • 当前宽度是否足够使 cardWidth 与步长有效。
  • 指针是否还在有效牌列区域内。
  • 算出的列号是否位于 0..<7。
  • 列间空隙是拒绝,还是归属相邻列。
  • 拖动途中布局尺寸变化后,命中区域是否仍正确。

不要只依赖 Int 截断就认为坐标已经可靠落在某一列。更复杂的布局可以使用实际子视图边界做命中测试。

十三、localSession:为什么这个示例主要验证 App 内拖动?

Game.validateMove(session:destination:) 会读取:

swift 复制代码
session.localSession?.draggedItemIDs(for: CardValue.self)

当前官方 DropSession 文档说明,localSession 用于识别从自己 App 内部开始的拖动,外部来源时为空。

示例取第一张拖动牌,判断它是否能接到目标列上。不同花色颜色交替、点数递减,才允许接到非空列末尾。

这也说明:

  • Transferable 定义了传输能力。
  • 目标是否接受该来源,由本 App 规则决定。
  • 使用 localSession 的这条验证路径,并没有证明支持陌生 App 拖来的牌。

如果业务确实允许外部导入,需要另行设计数据验证、导入去重与落点规则,不能把本地 ID 当作外部数据的可信证明。

十四、move 如何真正落地?答案在 Game 里

官方 Game.moveCards(difference:) 的核心是:

swift 复制代码
func moveCards(difference: ReorderDifference<CardValue, Card.Group>) {
    let cards = difference.sources.compactMap { value in
        cards.first { $0.value == value }
    }
    appendCards(cards, to: difference.destination.collectionID)
}

其中 appendCards 更新现有 Card 的:

  • groupID:它现在属于哪个组。
  • position:它在组里的顺序。

然后修正来源组的序号,并在适用时翻开来源列最后一张背面牌。

insertCards 也会用收到的 CardValue 在当前牌局中找出已有 Card,再调用同一套搬运逻辑。它没有简单地创建一批新的 SwiftData 记录。

这个示例不会复制牌,是因为业务层重新归属已有对象。 不能把效果全部归功于 allowMove: true。

如果来源和目标不是同一份模型呢?

官方 DragConfiguration 文档给出的模式是:观察 .onDragSessionUpdated,确认会话以 .ended(.move) 结束后,再执行来源清理。

但在当前纸牌工程中,目标已经移动了共享模型里的原对象,来源视图通过数据变化更新。如果再照搬一段"成功后删除源 Card"的逻辑,反而可能把刚搬过去的牌删掉。

因此要先确定模型所有权:是同一对象改归属,还是目标导入、来源清理。删除时机、失败恢复与事务一致性都属于业务层设计。

十五、拖动取消时,隐藏的牌怎样恢复?

RemainderView 还有一段容易被忽略的生命周期处理:

swift 复制代码
.onDragSessionUpdated { session in
    switch session.phase {
    case .initial, .active:
        hiddenCard = session.draggedItemIDs(for: CardValue.self).first
    case let .ended(operation):
        if operation == .forbidden || operation == .cancel {
            hiddenCard = nil
        }
    default:
        break
    }
}

以上为官方节选。hiddenCard 是这个 SwiftUI 结构体视图的状态。

起拖时先隐藏来源处那张牌,避免预览与原视图同时出现。禁止或取消时恢复;成功移动后,再结合当前牌变化更新显示。

onDragSessionUpdated 是观察拖动生命周期的入口,不等于最终数据库事务一定成功的证明。把 UI 清理与业务数据变更区分开,才能处理好取消、失败和下一张牌显示的时机。

十六、官方示例的简化,迁移时必须看清

这部分来自源码静态阅读,不是运行故障结论。

1. 牌局规则经过简化

validateMove 对空列直接返回 true,允许任意牌进入;不能把它描述成完整实现了"空列只能放 K"的常见 Klondike 规则。

2. 游戏层总是追加到末尾

moveCards 与 insertCards 使用目标集合 ID,最终调用 appendCards,没有实现通用的 .before(...) 插入。

因为示例配置了列尾目标,这样能配合演示。做任务看板或照片排序时,需要真正处理目标位置,不能只改所属列。

3. 多项有效性依赖现有牌堆状态

规则验证主要检查拖动序列的第一张牌,而 cardStack 根据现有位置取得整叠。它依赖当前牌局维持了合法序列,不能推广为任意外部多项输入都已完整验证。

4. Preview 的数组扩展只支持单项

difference.sources[0] 没有先检查空数组,且只处理第一个来源。生产实现要处理空来源、批量顺序、目标消失及失败时保持原数据。

5. 同名重载与平台范围要分别核对

dragContainer 同时提供单 ID 和 ID 数组回调,阅读时要打开具体重载,不能只看同名汇总页。预览队形的当前文档平台范围与视频描述不完全一致,也应结合目标 SDK 核对。

这些边界不影响理解设计思想,但会影响能否直接迁移到真实项目。

十七、在自己的项目里,建议按这个顺序接入

  1. 给项目设置稳定 ID,先确认重复内容不会共享身份。
  2. 只对一个集合接入 reorderable 与 reorderContainer。
  3. 在业务层正确处理 .before 与 .end,先验证数据更新。
  4. 扩展为多个集合,为各集合提供稳定 collectionID。
  5. 需要批量拖动时,再决定是多选,还是像纸牌一样由业务推导一组项目。
  6. 用 dragContainer 返回真正传输的数据。
  7. 接收容器外内容时添加 dropDestination。
  8. 使用 dragConfiguration 与 dropConfiguration 协调复制、移动及规则。
  9. 补上取消、拒绝、数据变化和来源清理。
  10. 最后调整预览队形等视觉效果,并按目标平台核对可用性。

如果采用 MVVM,让 View 负责声明修饰符、转发会话与呈现状态;由 ViewModel 或业务服务统一实施排序、规则验证和持久化。不要把移动算法、网络同步和错误恢复都塞进 View 的配置闭包。

推荐验收场景:同列向前与向后移动、跨列移动、多项保持顺序、空列、非法落点、拖到区域外取消、拖动期间目标被删除、保存失败以及重复触发提交。

本文核对了官方源码、在线符号文档及本机 Xcode 26.4.1 的相关 SwiftUI 接口声明;没有使用 27 系列 SDK 编译完整示例,也未执行模拟器运行。讲解改写用于说明实现思路,不作为已编译验证的完整工程代码。

十八、把整条主线串起来

纸牌示例的完整思路可以表示为:

text 复制代码
Transferable:数据怎样传
    ↓
reorderable + reorderContainer:哪些内容在哪个范围排序
    ↓
dragContainer:一次实际带走哪些项目
    ↓
dragConfiguration:来源允许哪些操作
    ↓
dropConfiguration:当前位置接不接受、采用什么操作、落在哪里
    ↓
排序闭包 / dropDestination:把动作交给业务层
    ↓
Game:更新归属与顺序,驱动视图刷新

掌握这条主线后,纸牌可以换成照片、待办事项、书签或者看板卡片。SwiftUI 负责提供拖放交互与结构化上下文,App 负责定义数据与操作真正代表什么。

参考资料与代码来源

以下视频、示例及符号文档均在本次写作中实际读取核对:

Transferable 的前置视频链接用于扩展阅读;本文相关代码核对来自下载工程的 CardValue.swift。官方原始版权与许可见工程中的 LICENSE.txt。

相关推荐
brien927 分钟前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance42 分钟前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M1 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写1 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李1 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易1 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马1 小时前
前沿视界
前端
kyriewen1 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易2 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒2 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端