Code-along: Build powerful drag and drop in SwiftUI
SwiftUI & UI Frameworks 进阶 15m

跟随编程:使用 SwiftUI 构建强大的拖放功能

Code-along: Build powerful drag and drop in SwiftUI

2026年6月10日

在 Apple 官方观看视频

一句话判断

SwiftUI 的拖放终于从“玩具级”进化到了“生产力级”,reorderContainer 让跨列/跨容器的复杂重排(比如看板、纸牌游戏)不再需要求助于 UIKit。

这场 Session 讲了什么

以前在 SwiftUI 里做拖放,如果是简单的列表内移动,用 List.onMove 就能搞定。一旦涉及网格、多列看板或者像纸牌游戏这种跨容器的复杂重排,draggabledropDestination 就显得非常鸡肋,开发者往往得自己算坐标、写一堆状态机,甚至直接桥接 UIKit。

这次 Apple 直接掀了桌子,推出了 reorderContainerreorderable 这对组合拳,把跨容器重排的脏活累活全包了。配合新增的 dragContainer 批量拖拽 API 和 dragConfiguration 意图配置,SwiftUI 现在能优雅地处理“选中5张照片拖到另一个相册,并显示堆叠预览,且明确是移动而非复制”这种硬核场景。

这对做效率工具、文件管理、看板或者卡牌游戏的开发者来说是巨大利好。你不再需要为了一个丝滑的拖拽动画去写几千行 UIKit 胶水代码,SwiftUI 原生就能搞定。

值得深挖的点

跨容器重排的降维打击:reorderContainer

以前 SwiftUI 的 ForEach 配合 .onMove 只能在单一维度(比如一个 List)里玩。如果你有一个 Trello 风格的看板,想把卡片从“Todo”列拖到“Doing”列,旧 API 会让你痛不欲生。你需要手动计算 Drop 位置,处理动画冲突,还要自己维护两个数组的数据同步。

reorderContainer 的设计哲学是把整个屏幕(或某个大区域)视为一个统一的拖放沙盒。你只需要在顶层容器(比如包含所有列的 HStack)挂上 reorderContainer,然后在每个可拖拽的子视图上挂 reorderable。系统会自动处理占位符(Placeholder)的生成、其他元素的避让动画,并在 Drop 发生时,通过闭包返回一个 CollectionDifference(集合差异)。

这里的 trade-off 是,Apple 没有替你修改数据源,而是把 Difference 交给你,让你调用 apply(difference:) 来更新模型。这非常符合 SwiftUI 单向数据流的理念,但也意味着你的数据模型必须严格遵循 Identifiable,且 ID 必须全局唯一(跨容器唯一),否则系统无法正确计算差异。如果你用简单的数组索引做 ID,跨列拖拽时绝对会翻车。

批量拖拽的视觉与意图控制

在 iPadOS 上,用多指选中多个文件然后拖拽是基操。但在 SwiftUI 里,以前拖拽多个元素时,预览图(Preview)往往只是一坨重叠的残影,或者干瘪瘪地显示一个数字 Badge。

这次引入的 dragPreviewsFormationdropPreviewsFormation 彻底解决了视觉表现力的问题。你可以自定义这堆被拖拽的卡片在悬空时是呈扇形散开,还是像纸牌一样整齐堆叠,甚至在靠近目标区域时产生磁吸或形变效果。

更关键的是 dragConfigurationdropConfiguration。以前拖放默认是 Copy(复制),想改成 Move(移动)得在 Drop 闭包里自己删原数据,很容易出现动画闪烁或状态不一致。现在,你可以在源头通过 dragConfiguration 声明意图是 .move,目标端通过 dropConfiguration 做最终裁决。这种“源端提议,目标端决策”的设计,完美复刻了 macOS 里按住 Command 键拖拽文件的系统级体验,让跨 App 或跨窗口拖拽的数据一致性有了底层保障。

代码片段

1. 跨容器重排的基础实现

场景:在一个包含多个牌堆的 HStack 中,允许纸牌跨堆拖拽重排。

HStack(alignment: .top) {
    // 遍历多个牌堆
    ForEach(piles) { pile in
        PileView(pile: pile)
    }
}
// 在最外层容器声明重排沙盒,指定数据类型
.reorderContainer(for: CardValue.self) { difference in
    // 系统计算出集合差异,开发者负责应用到数据源
    cards.apply(difference: difference)
}

// 在子视图 PileView 内部:
ForEach(cards) { card in
    CardView(card: card)
        // 标记为可重排,需提供跨容器唯一的 ID
        .reorderable(id: card.globalUniqueID) 
}

坑:reorderable 的 ID 必须在整个 reorderContainer 范围内唯一。如果两个牌堆里有 ID 相同的牌,拖拽时系统会无法区分,导致动画乱飞或数据错乱。

2. 声明拖拽意图为“移动”

场景:从发牌堆拖拽一张牌到游戏区,明确这是移动操作,原位置不应保留副本。

CardFaceView(card: currentCard.value)
    .draggable(containerItemID: currentCard.id) {
        // 拖拽时的预览视图
        CardFaceView(card: currentCard.value)
    }
    // 配置拖拽意图为 Move(移动)而非默认的 Copy(复制)
    .dragConfiguration {
        DragConfiguration(operation: .move)
    }

坑:如果你只设置了 dragConfiguration.move,但目标端的 dropDestination 没有正确处理删除原数据的逻辑,或者目标端用 dropConfiguration 强制覆盖了操作类型,源端的数据依然不会被自动删除。SwiftUI 只传递意图,不替你改数据。

最佳实践

对于已有项目,如果你的拖放仅限于单个 List 内的行移动,继续用 .onMove 即可,没必要强行迁移。但如果你的项目里有自定义布局的网格(LazyVGrid)、看板(多列 HStack/VStack)或者像相册那样的复杂视图,强烈建议把原来基于 draggabledropDestination 手搓的坐标计算逻辑删掉,换成 reorderContainer。这能帮你干掉至少 30% 的视图状态管理代码。

新项目在采用这些 API 时,第一步必须是打磨你的数据模型。确保你的模型实现了 Transferable(用于跨进程/跨 App 拖拽),并且为每个可拖拽元素生成全局唯一的 Identifiable ID。

实战中最容易踩的坑是“条件禁用拖拽”。比如纸牌游戏里背面朝上的牌不能拖。不要试图在 reorderable 外面包一层 if 判断,这会导致视图层级变化从而破坏重排动画。正确的做法是利用 reorderable 提供的 isEnabled 参数,或者在视图内部通过手势拦截来禁用,保持视图树结构的稳定。

还有什么值得关注

  • 条件禁用重排reorderable 支持传入条件参数,可以优雅地禁用特定元素(如背面朝上的纸牌)的拖拽交互,而不破坏整体布局。
  • 目标端的最终裁决权dropConfiguration 允许 Drop 目标根据自身的业务逻辑(如容量限制、规则校验)覆盖源端传来的拖拽意图。
  • 与 WWDC22 Transferable 的深度绑定:所有的新拖放 API 都强依赖于 Transferable 协议,没看过 WWDC22 那个 Session 的开发者建议先去补课,否则连数据都传不过去。
SwiftUI DragAndDrop Transferable reorderContainer