WWDC26 全网首发:SwiftUI 8 “可重排序“操作符深度解析

序幕:从龟仙人的训练场到 SwiftUI 的重排宇宙

布尔玛: "喂!悟空,贝吉塔,还有那边那个光头克林!天下第一武道会的登记表被你们弄乱了!谁准你们把弗利萨排在第一位的?!"

孙悟空(挠头笑): "嘿嘿,因为弗利萨看起来很强嘛,我想先跟他交手!"

贝吉塔(双手抱胸,冷哼): "哼,卡卡罗特。在战斗中,出场顺序就是一切。我怎么可能排在雅木茶后面?!"

在过去,如果我们想在 SwiftUI 的 List 里调整这群宇宙级战士的出场顺序,通常只有两条路:要么让用户点击"编辑"按钮,小心翼翼地拖动系统给的灰色小把手;要么就得像比克(Piccolo)训练悟饭那样,一拳一脚地去手搓 DragGesture 物理碰撞逻辑。

好在,WWDC26(iOS 27)为 SwiftUI 带来了更直接的"合体必杀技":.reorderable().reorderContainer(...)

今天,我们就借着这个龙珠测试项目,把这套全新招式从入门到实战彻底讲透。


第一章:天下第一武道会的出场顺序,为什么不能只看 UI?

ContentView.swift 里,武道会的前台接待处非常清爽:一个 NavigationStack,两个分会场入口。

swift 复制代码
NavigationLink {
    ListReorderDemoView()
} label: {
    DemoLinkRow(
        title: "List 重排演示",
        subtitle: "使用 List 测试 reorderable()",
        imageName: "B1"
    )
}

NavigationLink {
    LazyVGridReorderDemoView()
} label: {
    DemoLinkRow(
        title: "LazyVGrid 重排演示",
        subtitle: "使用 LazyVGrid 测试 reorderable()",
        imageName: "B2"
    )
}

这两个入口分别对应了本次 SwiftUI 重排新特性的两个典型战场:

  • List(传统擂台):一行一个选手,纪律严明。
  • LazyVGrid(龙珠雷达网格):一屏多个格子,错落有致。

布尔玛的技术笔记:

"大家注意,重排的重点从来都不是'卡片能不能在屏幕上飘起来',而是'松手的那一瞬间,底层的数据模型有没有老老实实地跟着变'。SwiftUI 这次的设计非常聪明,它把重排拆成了两步:"

  1. 谁能被拖着走? ------ 挂上属性 .reorderable()
  2. 落地后数据怎么变? ------ 交给外层容器的 .reorderContainer(...) 闭包。

UI 负责展示肉眼可见的"残像拳",而数据层则负责接住最终的"战斗结果"。


第二章:悟空先上场:List 里的最小可用重排

让我们先来看大弟子悟空在 ListReorderDemoView.swift 里的基本演示。

swift 复制代码
struct ListReorderDemoView: View {
    @State private var numbers = Array(0...9)

    var body: some View {
        List {
            ForEach(numbers) { i in
                VStack(alignment: .leading, spacing: 10) {
                    HStack(spacing: 18) {
                        Image("B\(i + 1)")
                            .resizable()
                            .scaledToFill()
                            .frame(width: 112, height: 112)
                            .clipShape(RoundedRectangle(cornerRadius: 10))

                        Text("\(i)")
                            .font(.system(size: 88))
                    }
                }
            }
            .reorderable() // 标记:这里的每一行都是可拖动的战士
        }
        .reorderContainer(for: Int.self) { diff in // 告诉容器:这里是 Int 选手的专属擂台
            diff.apply(to: &numbers) // 裁判宣布最终结果,直接修改数据源
        }
    }
}

龟仙人(拄着拐杖,戴着墨镜):

"咳咳,悟空啊,你这个代码能跑通,全亏了后山那段悄悄给 Int 补上的'外挂':"

swift 复制代码
extension Int: @retroactive Identifiable {
    public var id: Int { self }
}

因为 .reorderContainer(for:) 内部有着严格的家规:被重排的类型必须符合 Identifiable 协议,以便系统能够跨线程安全地识别每个元素。

虽然我们在测试时可以用 retroactive 让标准库的 Int 直接用自身当 id,但在真正的生产项目(比如卡普空或万代的游戏开发)中,贝吉塔是绝对不允许这种偷懒行为的。更标准的"赛亚人写法"应该是定义明确的模型:

swift 复制代码
struct Fighter: Identifiable, Sendable {
    let id: Int          // 唯一的战斗员编号
    var name: String     // 姓名,比如"孙悟空"
    var imageName: String // 照片
}

这样既能避免给系统标准库类型乱打补丁,也能防止与其他模块发生协议冲突,稳如泰山。


第三章:贝吉塔不服:为什么是 ReorderDifference,而不是 From/To Index?

在过去,旧的列表重排我们通常使用 .onMove 1。系统会扔给你一个 IndexSet(源下标集)和一个目标 offset(偏移量),然后你调用数组的 move(fromOffsets:toOffset:)

贝吉塔(气得头发更竖了):

"荒谬!愚蠢!卡卡罗特,如果弗利萨那家伙突然用气弹把克林炸飞了,整个数组的长度瞬间缩水,我原本在第 4 位的索引不就变成第 3 位了?!在这种瞬息万变的战场上,用不稳定的'视觉索引(Index)'来定位战士,简直就是找死!"

贝吉塔一针见血。WWDC26 彻底抛弃了脆弱的下标思维,转而引入了 ReorderDifference

ReorderDifference 不再关心"第几行移动到第几行",而是记录 "谁(Identity)移动到了哪里"

  • sources :哪些战士被拖走了(保存的是这群战士的 id,哪怕弗利萨中途乱入,这些 id 依旧唯一且正确)。
  • destination:插入的目标位置(例如"插入到贝吉塔这个 ID 的前面",或者是"放到队伍末尾")。
  • CollectionID:如果现场有多个擂台(比如多组 Section),指出这次移动发生在哪一个擂台上。

这样一来,即使你的数据源在拖拽过程中因为网络同步、后台刷新而发生改变,重排逻辑也不会发生"张冠李戴"的悲剧。


第四章:比克老师拆招:自定义的 apply(to:) 为什么要用 OrderedDictionary

在这个演示项目中,我们并没有直接使用系统默认的 apply,而是自己手写了一个优雅的扩展。

比克(双臂抱胸,头蓬迎风飘扬):

"悟饭!别光看着!仔细看这段逻辑!这才是真正的气功运行路线:"

swift 复制代码
import Collections // 引入官方 swift-collections 库

extension ReorderDifference
where CollectionID == ReorderableSingleCollectionIdentifier {

    func apply(
        to values: inout [some Identifiable<ItemID>]
    ) {
        // 1. 先用当前的战士数组,生成一个以 ID 为 Key、战士本体为 Value 的有序字典
        var dictionary = OrderedDictionary(
            uniqueKeys: values.map(\.id),
            values: values
        )

        // 2. 找到目标落脚点的 Offset 到底在当前字典的哪个位置
        let destinationOffset: Int? =
            switch destination.position {
            case .before(let destination):
                dictionary.keys.firstIndex(of: destination)
            case .end:
                nil
            }

        // 3. 将被拖拽的那批战士(sources),整体迁徙到目标落脚点
        dictionary.move(
            keys: sources,
            to: destinationOffset ?? values.endIndex
        )

        // 4. 将调整完毕的字典重新写回原数组
        values = dictionary.values.elements
    }
}

比克老师解释道,这里引入 OrderedDictionary 主要有两个原因:

  1. 像普通字典一样通过 ID 查找 :能极速响应 ReorderDifference.sources 这种基于"身份(Identity)"的移动指令。
  2. 像普通数组一样保持顺序:方便完美将排序结果呈现在 UI 上。

这正是"有序 + Key 检索"的完美结合,也是我们依赖 swift-collections 的底气所在。


第五章:龙珠雷达展开:LazyVGrid 也能用同一套重排模型

接着,我们把战场转移到 LazyVGridReorderDemoView.swift

swift 复制代码
struct LazyVGridReorderDemoView: View {
    @State private var photos = Array(0...9)

    private let columns = [
        GridItem(.adaptive(minimum: 150), spacing: 16)
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(photos) { photoIndex in
                    VStack(alignment: .leading, spacing: 8) {
                        Image("B\(photoIndex + 1)")
                            .resizable()
                            .scaledToFill()
                            .frame(minWidth: 0, maxWidth: .infinity)
                            .aspectRatio(1, contentMode: .fill)
                            .clipShape(RoundedRectangle(cornerRadius: 10))
                        Text("图片 \(photoIndex + 1)")
                    }
                }
                .reorderable() // 瞧,也是只要在这里挂载即可!
            }
            .reorderContainer(for: Int.self) { diff in
                diff.apply(to: &photos) // 同样的一招制敌!
            }
        }
    }
}

布尔玛(得意地晃了晃手中的雷达):

"看吧!在我的雷达网格里,重排的代码几乎一字未改!这就是 WWDC26 重排 API 的最伟大之处------它不再是 List 的专属特权!"

过去我们要写网格重排,手势检测、计算坐标、处理插值,代码写得像那美克星的历史一样冗长。

现在,不管是单列列表,还是动态网格,底层都共享着同一套 ReorderDifference 模型。


第六章:界王星的修炼禁忌:.reorderable() 应该挂在哪里?

北界王(抱着猩猩巴布鲁斯,一脸严肃):

"听好了悟空,如果你把气功的姿势摆错了,不仅打不中敌人,可能还会把界王星给炸了!这两个 Modifier 的位置绝对不能放反!"

我们必须牢记:

  • .reorderable() 必须挂在 ForEach 的后面。它修饰的是 DynamicViewContent,作用是标记哪些动态生成的数据项可以被抓起来拖拽
  • .reorderContainer(...) 必须挂在承载这些子项的外层物理容器 (如 ListLazyVGridScrollView)上。
swift 复制代码
List { // 外层大容器
    ForEach(numbers) { i in // 动态数据集
        RowView(i)
    }
    .reorderable() // 标记:"这群人可以被拖动"
}
.reorderContainer(for: Int.self) { diff in // 接收:"在这里发生碰撞,并以此更新数据"
    diff.apply(to: &numbers)
}

一句话口诀: .reorderable() 标记谁能动.reorderContainer(...) 决定怎么变


第七章:从单擂台到多宇宙:挑战"力之大会"

如果你的 App 里只有这十张卡片在自己家里拖来拖去,那只能算"天下第一武道会"。

但如果我们要支持更复杂的场景:

  • 任务看板:任务卡片要在"待办"、"进行中"、"已完成"三个泳道(Section)之间自由拖动;
  • 星球转移:把孙悟空从"地球"拖到"界王星"的列表里。

这时候,我们就需要召唤大天官的"多宇宙重排模式":

swift 复制代码
// 1. 在 ForEach 里,通过 collectionID 表明当前子项属于哪个阵营/宇宙
ForEach(universe.fighters) { fighter in
    FighterRow(fighter)
}
.reorderable(collectionID: universe.id) // 绑定阵营 ID
swift 复制代码
// 2. 外部容器指定 Item 的类型以及 Section (Collection) 的 ID 类型
.reorderContainer(for: Fighter.self, in: Universe.ID.self) { diff in
    // 此时的 diff 不仅包含被拖拽的战士,还能分辨出他们是从哪个宇宙跨越到哪个宇宙的!
    model.applyCrossUniverseDifference(diff)
}

第八章:收招:当悟空放下龟派气功,SwiftUI 也放下了下标思维

在整篇文章的最后,让我们用一张简单的表来复盘这场技术革命:

维度 过去(iOS 20 之前) 现代(WWDC26 / iOS 20 时代)
支持的布局 几乎仅限 List ListVStack/HStackLazyVGrid、自定义 Layout
拖拽标识 系统把手(EditMode)/ 手写 DragGesture 优雅声明式的 .reorderable()
数据同步机制 基于不稳定的位置下标 IndexSet 基于稳定实体标识的 ReorderDifference

这正如悟空在力量大会上不再一味追求爆气,而是追求卸去多余负担、顺应身体本能的"自在极意功"一样------SwiftUI 也在逐渐卸去繁琐的手势胶水代码。

它让我们专注于声明最本质的逻辑:

  • 把战士交出去(.reorderable()
  • 把规则定下来(.reorderContainer
  • 剩下的,放心地让 SwiftUI 去替我们安排。

下一次,当你要写一个拖动排序列表时,先别急着手搓手势。试着像布尔玛一样喝杯咖啡,气定神闲地写下这两行新招式,然后优雅地对系统说一句:

"剩下的,你先出一招吧!"

感谢宝子们的观赏,再会吧!8-)

相关推荐
黑化旺仔1 小时前
【OC】KVO
macos·ios·objective-c·cocoa
HouWan3 小时前
SwiftUI 小技巧:如何读取 SwiftUI Font 的详细信息
ios·swiftui·apple
李游Leo3 小时前
HarmonyOS 7 实战开发 05:把页面做到可上线状态
ios·harmonyos
终端安全笔记9 小时前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
传奇开心果编程9 小时前
【SwiftUI入门练中学】第1课 从零开始
学习·ui·ios·swiftui·swift
丿Wayne15 小时前
iOS Token 刷新实战(Actor 避免并发重复刷新)
ios·swift·token·actor·swift并发
终端安全笔记1 天前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩1 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年1 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架