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

交互示意动画,非官方示例实机录屏。
这些看似相近的交互,实际上包含三个不同的问题:内容怎样重新排序,一次拖走哪些数据,目标是否允许这次操作。
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。
一、先找到官方工程
官方入口:
- 视频:Code-along: Build powerful drag and drop in SwiftUI
- 示例:Making a card game with drag, drop, and reordering in SwiftUI
- 本次下载的官方 ZIP
- 前置视频:Meet Transferable,WWDC22
长期收藏建议使用示例文档入口,下载地址可能更新。
工程主线很清楚:
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 | 本次文档未列出 |
这里有两个容易忽略的区别:
- 配置类型与使用它的修饰符是不同符号,可能有不同的可用版本。
- 视频介绍的平台范围与当前符号页面不完全一致。视频说多项拖拽相关修饰符扩展到 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
)
}
这段代码完成三件事:
- 根据会话位置推算玩家指向哪一列。
- 把目标明确设为该列末尾。
- 检查来源支持移动,并让游戏规则决定允许还是禁止。
几个新名字逐个解释
| 符号 | 含义 |
|---|---|
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 核对。
这些边界不影响理解设计思想,但会影响能否直接迁移到真实项目。
十七、在自己的项目里,建议按这个顺序接入
- 给项目设置稳定 ID,先确认重复内容不会共享身份。
- 只对一个集合接入
reorderable与reorderContainer。 - 在业务层正确处理
.before与.end,先验证数据更新。 - 扩展为多个集合,为各集合提供稳定
collectionID。 - 需要批量拖动时,再决定是多选,还是像纸牌一样由业务推导一组项目。
- 用
dragContainer返回真正传输的数据。 - 接收容器外内容时添加
dropDestination。 - 使用
dragConfiguration与dropConfiguration协调复制、移动及规则。 - 补上取消、拒绝、数据变化和来源清理。
- 最后调整预览队形等视觉效果,并按目标平台核对可用性。
如果采用 MVVM,让 View 负责声明修饰符、转发会话与呈现状态;由 ViewModel 或业务服务统一实施排序、规则验证和持久化。不要把移动算法、网络同步和错误恢复都塞进 View 的配置闭包。
推荐验收场景:同列向前与向后移动、跨列移动、多项保持顺序、空列、非法落点、拖到区域外取消、拖动期间目标被删除、保存失败以及重复触发提交。
本文核对了官方源码、在线符号文档及本机 Xcode 26.4.1 的相关 SwiftUI 接口声明;没有使用 27 系列 SDK 编译完整示例,也未执行模拟器运行。讲解改写用于说明实现思路,不作为已编译验证的完整工程代码。
十八、把整条主线串起来
纸牌示例的完整思路可以表示为:
text
Transferable:数据怎样传
↓
reorderable + reorderContainer:哪些内容在哪个范围排序
↓
dragContainer:一次实际带走哪些项目
↓
dragConfiguration:来源允许哪些操作
↓
dropConfiguration:当前位置接不接受、采用什么操作、落在哪里
↓
排序闭包 / dropDestination:把动作交给业务层
↓
Game:更新归属与顺序,驱动视图刷新
掌握这条主线后,纸牌可以换成照片、待办事项、书签或者看板卡片。SwiftUI 负责提供拖放交互与结构化上下文,App 负责定义数据与操作真正代表什么。
参考资料与代码来源
以下视频、示例及符号文档均在本次写作中实际读取核对:
- WWDC26 271:Code-along: Build powerful drag and drop in SwiftUI
- 官方纸牌示例及下载入口
- SwiftUI:Drag and drop
- Reordering items in lists, stacks, grids, and custom layouts
- reorderable()
- reorderContainer(for:in:isEnabled:move:)
- ReorderDifference
- dragContainer:ID 数组回调
- dragContainer:单 ID 回调
- DragConfiguration
- dragConfiguration(_:)
- DropConfiguration
- dropConfiguration(_:)
- DropSession
- dragPreviewsFormation(_:)
- dropPreviewsFormation(_:)
Transferable 的前置视频链接用于扩展阅读;本文相关代码核对来自下载工程的 CardValue.swift。官方原始版权与许可见工程中的 LICENSE.txt。