Highlight
SwiftUI 新增
reorderable、reorderContainer、dragContainer和dragConfiguration四个 API,让跨容器重排、批量拖拽和意图控制不再需要桥接 UIKit,纯 SwiftUI 即可实现纸牌游戏级别的复杂拖放交互。
核心内容
从 List.onMove 到跨容器重排
以前 SwiftUI 里做拖放,列表内移动用 List 的 .onMove 就能搞定。一旦涉及多列看板、网格布局,或者像纸牌游戏这种跨容器的复杂重排,draggable 和 dropDestination 就显得力不从心。开发者得自己算坐标、维护状态机,甚至桥接 UIKit。
Apple 这次推出了 reorderContainer 和 reorderable,把跨容器重排的脏活累活全包了。
(02:11)演讲者以纸牌游戏为例,展示了 reorderable 的基本用法。给 ForEach 里的子视图加上 .reorderable(),再给外层容器加上 .reorderContainer(for: CardValue.self),系统就会自动处理占位符、避让动画和位置计算。
(03:54)当拖拽一个视图时,它从视图层级中被提起,原位置留下一个空占位符。拖动过程中,其他元素会自动让出空间,占位符实时更新以反映放置位置。松手后,卡片移动到新位置。
多牌堆场景:用 collectionID 区分容器
(04:38)纸牌游戏有多个牌堆,需要让卡片在牌堆之间自由移动。reorderContainer 支持通过 collectionID 参数标识不同的子容器。
在 GameView 的 HStack 上声明 reorderContainer,指定 CardValue 为元素类型、Card.Group 为容器类型。然后在 PileView 内部,给可拖拽的卡片加上 reorderable(collectionID:),每个牌堆用唯一的 collectionID 标识。
(05:58)这里有个实用技巧:背面朝上的牌不应该能被拖动。做法是把牌堆分成两个 ForEach——第一个遍历背面朝上的牌,不加 reorderable;第二个遍历正面朝上的牌,加上 .reorderable(collectionID:)。这样背面朝上的牌自然无法被拖拽,无需额外的状态判断。
批量拖拽:一次拖动多张牌
(06:57)纸牌游戏的核心玩法之一是拖动一张牌时,它上面叠着的牌要一起跟着走。这需要用到 dragContainer API。
reorderContainer 隐式提供了 dragContainer 和 dropDestination 能力,但你可以显式声明自己的 dragContainer 来定制行为。在闭包里,你拿到被拖拽元素的 ID,返回一组要一起拖动的元素。
(07:50)演讲者在 GameView 上添加了 dragContainer(for: CardValue.self),在闭包里调用 game.cardStack(startingAt: cardID) 获取从指定牌开始的所有叠牌。拖动四张梅花时,它上面的方块三也会一起被提起。
自定义拖拽预览样式
(08:45)多张牌一起拖动时,默认预览会把它们堆成一摞。你可以用 dragPreviewsFormation 自定义这个外观,可选值包括 .stack、.pile、.list 等。
演讲者选择了 .stack,因为纸牌堆叠的视觉效果最符合游戏直觉。同时,为了让拖拽过程中预览样式保持一致,还在根布局上加了 .dropPreviewsFormation(.stack),这样牌在悬停于目标区域上方时也不会改变外观。
控制数据传输意图:Move 还是 Copy
(10:05)SwiftUI 的拖放默认按 Copy 处理。在纸牌游戏里,从牌库拖一张牌到牌堆,应该是移动(Move)而不是复制。这需要用到新的 Drag Configuration API。
(11:40)在牌库的 RemainderView 中,给当前正面朝上的牌加上 .dragConfiguration(DragConfiguration(allowMove: true)),声明传输意图为移动。
(12:05)然后在 GameView 的 reorderContainer 下方添加 dropDestination,接收 CardValue 类型的新卡片。在 dropConfiguration 闭包里做三件事:
- 计算拖拽位置对应的牌堆索引
- 检查当前操作是否包含
.move - 用游戏逻辑验证这次移动是否合法
如果移动不合法(比如把方块 Q 放到方块 7 上面),返回 .forbidden,牌会自动弹回原位。
详细内容
基础重排:Preview 中的快速验证
(03:40)在正式集成到游戏之前,可以先在 Preview 里验证重排效果:
#Preview {
@Previewable @State var cards = [
CardValue(rank: .ace, suit: .clubs),
CardValue(rank: .ace, suit: .diamonds),
CardValue(rank: .ace, suit: .hearts),
CardValue(rank: .ace, suit: .spades)
]
HStack {
ForEach(cards) { card in
CardFaceView(card: card)
}
.reorderable()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.reorderContainer(for: CardValue.self) { difference in
cards.apply(difference: difference)
}
.padding()
.background(.green.gradient)
}
关键点:
.reorderable()加在ForEach上,标记这个集合里的元素可以被重排.reorderContainer(for:)加在容器上,声明这个区域是一个重排沙盒- 闭包里的
difference是CollectionDifference<CardValue>,调用apply(difference:)直接更新数组 CardValue必须遵循Identifiable且 ID 全局唯一
跨牌堆重排:GameView 中的完整配置
(04:40)在游戏主视图中,把所有牌堆纳入同一个重排容器:
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
}
}
.padding()
}
}
关键点:
reorderContainer有两个泛型参数:元素类型CardValue和容器类型Card.GroupCard.Group用来唯一标识每个牌堆,比如.pile(0)、.pile(1)等- 闭包里的
difference包含了跨牌堆的移动信息,game.moveCards(difference:)负责更新数据模型
牌堆视图:区分可拖拽和不可拖拽元素
(05:58)在 PileView 中,背面朝上的牌不能拖,正面朝上的牌可以拖:
struct PileView: View {
var game: Game
var index: Int
@Query var cards: [Card]
var body: some View {
ZStack(alignment: .topLeading) {
CardPlaceholderView()
PileLayout {
let index = firstFaceUpIndex
ForEach(cards[..<index]) { card in
CardView(card: card)
}
ForEach(cards[index...], id: \.value) { card in
CardView(card: card)
}
.reorderable(collectionID: Card.Group.pile(index))
}
}
}
var firstFaceUpIndex: Int {
cards.firstIndex { !$0.isFaceDown } ?? cards.endIndex
}
}
关键点:
- 两个
ForEach分别处理背面朝上和正面朝上的牌 - 只有第二个
ForEach加了.reorderable(collectionID:) collectionID必须是Card.Group类型,与reorderContainer声明的容器类型一致- 背面朝上的牌因为没有
reorderable,自然无法被拖动
批量拖拽:dragContainer 实现叠牌跟随
(07:50)让拖动一张牌时,它上面的牌一起跟着走:
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
game.cardStack(startingAt: cardID)
}
关键点:
dragContainer必须声明在reorderContainer之后- 两个 modifier 使用相同的元素类型
CardValue - 闭包接收被拖拽的牌 ID,返回一组要一起拖动的
CardValue game.cardStack(startingAt:)根据游戏规则计算叠牌列表
自定义预览样式
(08:45)控制多张牌拖动时的视觉呈现:
.dragContainer(for: CardValue.self) { cardID in
game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)
(09:14)在根布局上加 dropPreviewsFormation,保持拖拽全程视觉一致:
VStack {
// ... 游戏布局 ...
}
.dropPreviewsFormation(.stack)
关键点:
.dragPreviewsFormation(.stack)让被拖动的牌以堆叠方式呈现.dropPreviewsFormation(.stack)让牌在悬停于目标上方时保持堆叠样式- 可选值包括
.stack、.pile、.list等
从牌库拖牌到牌堆:完整的 Move 配置
(11:40)牌库中的当前牌声明移动意图:
struct RemainderView: View {
@Query var cards: [Card]
var game: Game
var body: some View {
// ...
ZStack {
CardPlaceholderView()
if let currentCard {
CardFaceView(card: currentCard.value)
.draggable(containerItemID: currentCard.value)
.opacity(currentCard.value == hiddenCard ? 0 : 1)
}
}
.dragContainer(for: CardValue.self) { cardID in
[cardID]
}
.dragConfiguration(DragConfiguration(allowMove: true))
}
}
关键点:
.draggable(containerItemID:)声明这个视图可以被拖拽,并指定传输的数据.dragConfiguration(DragConfiguration(allowMove: true))声明传输意图为移动- 牌库的
dragContainer只返回单张牌[cardID],因为牌库一次只出一张
(12:05)在 GameView 中配置 Drop 目标,接收牌库拖来的牌并校验规则:
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)
.dragConfiguration(DragConfiguration(allowMove: true))
.dropDestination(for: CardValue.self) { newCards, session in
if let destination = session.reorderDestination(
for: CardValue.self, in: Card.Group.self) {
game.insertCards(newCards, to: destination)
}
}
.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)
}
关键点:
.dropDestination(for:)声明这个区域可以接收CardValue类型的拖拽数据session.reorderDestination(for:in:)从reorderContainer获取目标位置信息.dropConfiguration是最终决策点,返回DropConfiguration决定操作类型和目标位置session.location获取当前拖拽位置,用于计算落在哪个牌堆session.suggestedOperations.contains(.move)检查源端是否提议移动game.validateMove(session:destination:)用游戏规则验证这次移动是否合法- 如果不合法,返回
.forbidden,牌自动弹回原位
核心启发
做一个支持拖拽排序的看板应用
用 reorderContainer 和 collectionID 实现 Trello 风格的多列看板。每列是一个 collectionID,卡片在不同列之间拖拽时,系统自动计算 CollectionDifference。入口 API 是 reorderContainer(for:in:) 和 reorderable(collectionID:)。
做一个批量整理照片的相册应用
用 dragContainer 实现多选照片后批量拖拽。用户先点击选中多张照片,然后拖动其中任意一张,所有选中的照片一起跟着走。用 dragPreviewsFormation(.stack) 让预览以堆叠方式呈现,视觉上清晰表达”多张照片”的概念。
做一个支持规则校验的卡牌或棋盘游戏
用 dropConfiguration 在闭包里实现游戏规则校验。比如象棋里,拖拽棋子到非法位置时返回 .forbidden,棋子自动弹回。这比手动写动画和状态回滚简单得多,SwiftUI 自动处理”弹回”动画。
做一个文件管理器风格的侧边栏
用 dragConfiguration(DragConfiguration(allowMove: true)) 实现文件夹之间的文件移动。源端声明移动意图,目标端用 dropConfiguration 做最终裁决。如果目标文件夹已满或权限不足,返回 .forbidden。
做一个支持跨列表重排的设置面板
把多个设置分组做成独立的 List 或 VStack,用 reorderContainer 把它们纳入同一个重排沙盒。用户可以把设置项从一个分组拖到另一个分组,数据模型通过 CollectionDifference 自动同步。
关联 Session
- 272 - SwiftUI + AppKit/UIKit — 学习如何在 SwiftUI 中桥接 AppKit/UIKit 的拖放能力,与本 Session 的纯 SwiftUI 方案形成互补
- 269 - SwiftUI — SwiftUI 整体新特性概览,了解拖放 API 在整个框架中的位置
- 321 - Lazy stacks — 拖放场景常与大数据列表配合,Lazy stacks 的性能优化对流畅拖拽很关键
- 278 - UIKit modernization — 如果需要在 UIKit 中实现类似能力,了解 UIKit 的现代化拖放 API
- 370 - TextKit — 文本编辑场景中的拖放处理,与 SwiftUI 拖放 API 可以结合使用
评论
GitHub Issues · utterances