要在 Mac 应用中重排列表,请将 SwiftUI 的 onMove 源索引和目标索引直接传给 move(fromOffsets:toOffset:)。目标索引表示原始集合中的一个边界,因此向下移动时手动减去 1 会导致结果错误。
本指南将使用合成标签构建一个小型原生 macOS 列表,解释目标索引的计算方式,并提供一个用于排查行位置错误的测试矩阵。内容范围仅限于在你自己的应用列表中重排项目。
onMove 的目标索引表示什么?
Apple 将这两个 onMove(perform:) 参数定义为相对于动态视图底层集合的偏移量。第一个参数是一个包含源偏移量的 IndexSet;第二个参数是目标偏移量。这些位置属于该 ForEach 所用的集合。Apple 的 onMove(perform:) 文档。
对于 move(fromOffsets:toOffset:),Apple 将目标位置定义为插入移动元素之前的偏移量,同时也允许用 count 表示末尾。可以把它理解为原始集合中元素之间的边界。Apple 的 move(fromOffsets:toOffset:) 文档。
假设原始顺序为 A, B, C, D, E。元素偏移量为 0, 1, 2, 3, 4,插入边界则从 0 到 5。边界 4 紧挨在 E 之前;边界 5 位于最后一个项目之后。
因此,将 B 从源偏移量 1 移至目标位置 4,结果是 A, C, D, B, E。移动后的项目位于索引 3,尽管目标参数的值是 4。这两个值都使用从零开始的位置,但描述的是操作的不同阶段。
这一区别解释了常见的向下移动错误。如果在调用集合的移动方法之前,将目标位置 4 改为 3,代码请求的就是另一个边界,导致 B 被放到 D 之前。使用 Apple 的移动方法时,应原样传递回调值。
构建完整的可重排 macOS 列表
在 Xcode 中使用原生 macOS SwiftUI 应用项目。下面的完整示例面向 macOS 11 或更高版本,因为它使用了 SwiftUI 的 App 生命周期;Apple 列出的 onMove 最低支持版本为 macOS 10.15。用这段代码替换生成的应用和内容视图声明,并确保目标中只有一个 @main 入口点。Apple 的 App 文档,Apple 的 onMove(perform:) 文档。
import SwiftUI
import Foundation
struct DemoItem: Identifiable {
let id: Int
let label: String
static let samples: [DemoItem] = [
DemoItem(id: 0, label: "A"),
DemoItem(id: 1, label: "B"),
DemoItem(id: 2, label: "C"),
DemoItem(id: 3, label: "D"),
DemoItem(id: 4, label: "E")
]
}
@main
struct ReorderDemoApp: App {
var body: some Scene {
WindowGroup {
ReorderView()
}
}
}
struct ReorderView: View {
@State private var items = DemoItem.samples
@State private var selection = Set<Int>()
@State private var lastMove = "No move yet"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Drag rows to change their order")
List(selection: $selection) {
ForEach(items) { item in
Text(item.label)
.tag(item.id)
}
.onMove(perform: moveItems)
}
Text("Order: " + items.map(\.label).joined(separator: ", "))
Text(lastMove)
Button("Reset") {
items = DemoItem.samples
selection.removeAll()
lastMove = "No move yet"
}
}
.padding()
.frame(minWidth: 420, minHeight: 300)
}
private func moveItems(from source: IndexSet, to destination: Int) {
lastMove = "Source: \(Array(source)); destination: \(destination)"
items.move(fromOffsets: source, toOffset: destination)
}
}
ForEach 渲染的数组与处理程序修改的数组相同。每个项目都有一个独立于当前位置的固定标识符。Apple 要求 ForEach 的元素可识别,或显式指定身份键;移动时保留每个标识符,可让视图继续表示同一个项目。Apple 的 ForEach 文档。
数组是视图的本地状态。修改数组会使 SwiftUI 更新依赖它的视图,包括列表及其显示顺序。本示例只在内存中保存顺序,不包含持久化实现。Apple 的 State 文档。
运行应用,将 B 拖到紧挨在 E 之前的边界,并检查顺序和回调显示。尝试其他场景前,请使用 Reset。显式的选择绑定也支持多行选择;Apple 介绍了选择集合以及 Mac 上无需进入编辑模式的选择方式。Apple 的 List 文档。
不要在这个原生 macOS 示例中加入 EditButton()。Apple 当前的可用性信息涵盖 iOS 和 Mac Catalyst,但不包括原生 macOS。将教程中的移动端编辑控件用于 Mac 目标之前,应先检查其可用性。Apple 的 EditButton 文档。
如何计算最终插入索引?
只有在自行实现移除和插入,或要将移动操作转换为另一种模型操作时,才需要进行此计算。这是根据 Apple 文档中的插入语义推导出的解释模型,不是在调用内置移动方法之前需要额外进行的调整。
计算方式如下:
移除后的插入索引 = 原始目标位置
- 目标位置之前的源偏移量数量
对于源位置 {1} 和目标位置 4,原先有一个被移除的元素位于该边界之前。缩减后的数组为 A, C, D, E,插入索引为 4 - 1 = 3。在该位置插入 B,结果是 A, C, D, B, E。
存在多个源元素时,只向下调整一次是不够的。源位置为 {1, 3}、目标位置为 5 时,移动的项目是 B, D;剩余数组为 A, C, E。目标位置 5 之前有两个源偏移量,因此插入索引为 3。结果是 A, C, E, B, D。
自定义实现必须先按原始顺序捕获要移动的元素,然后移除原元素,同时避免先前的移除操作导致后续源位置失效。接着计算调整后的插入位置,并一次性插入捕获的元素块。对于这个基于数组的列表,Apple 已有的方法可以省去这些步骤。
应该测试哪些边界情况?
以下是以原始顺序 A, B, C, D, E 为例的计算。每种情况开始前都应重置为该顺序。最后一列是预期的模型结果,与放置指示器的显示方式无关。
| 源偏移量 | 原始目标位置 | 移除后的插入索引 | 预期顺序 |
|---|---|---|---|
{1} |
4 |
3 |
A, C, D, B, E |
{3} |
1 |
1 |
A, D, B, C, E |
{1} |
5 |
4 |
A, C, D, E, B |
{1} |
1 |
1 |
A, B, C, D, E |
{1} |
2 |
1 |
A, B, C, D, E |
{1, 3} |
5 |
3 |
A, C, E, B, D |
{1, 3} |
3 |
2 |
A, C, B, D, E |
两个单项无操作情况值得注意:将项目插入其自身边界或后一个边界,都会保持其顺序不变。Apple 对此行为有明确说明。末尾边界同样有效,尽管它不能用于访问某个元素的下标。Apple 的 move(fromOffsets:toOffset:) 文档。
这七种情况背后的计算模型已通过一个简短的确定性 Python 计算进行检查。本文检查了 SwiftUI 程序,但由于当时没有 Swift 工具链和 macOS 运行环境,因此未进行编译或运行。请在目标 macOS 版本上验证实际拖动交互。
为什么行仍然会移动到错误位置?
先检查集合边界,再检查身份和修改方式。按此顺序排查,可以区分目标索引计算问题与显示行和存储数据不一致的问题。
- 检查传给
ForEach的集合。 如果它显示的是经过筛选的数组,其偏移量描述的就是该筛选数组。例如,可见行B, D的偏移量是0, 1;将源位置0应用到完整数组,会移动A。这是 Apple 文档所述集合相对回调约定的直接结果。Apple 的onMove(perform:)文档。 - 检查渲染时是否再次对数据排序。 如果每次重绘都会按标签排序,修改底层数组的顺序就无法建立持久的手动显示顺序。在该视图启用重排前,应先确定手动排序策略。
- 检查标识符。 移动项目时保留其标识符。不要在渲染时重新生成标识符,也不要用会变化的位置作为可移动项目的身份。Apple 的
ForEach文档。 - 检查是否重复执行。 移除手动调整目标位置的操作,然后确认处理程序只对权威集合应用一次移动。第二次移动会作用于已经改变的顺序。
对于经过筛选或分区的界面,应先确定预期行为,再转换索引。决定移动操作是只调整可见项目、改变整个集合,还是将项目移到另一个分区。将可见项目映射到模型身份,并根据该策略确定目标位置。单个数字偏移量无法表达所有这些选择。
回调偏移量应始终与其所描述的集合快照对应。如果应用延迟执行操作期间数组发生了其他变化,应重新解析预期项目和目标位置,而不是重放过时的位置。本示例立即修改本地数组,因此不会遇到这一问题。
AppKit 或 TextKit 会改变目标位置规则吗?
如果应用已在使用 NSTableView,AppKit 有自己的放置约定。NSTableView.DropOperation.above 表示放置在某行上方,.on 表示放置在该行上。Apple 将最后一行下方的放置定义为行 n,操作为 .above,其中 n 是行数。将 AppKit 放置转换为模型移动操作之前,应先解释行号和操作类型。Apple 的 NSTableView.DropOperation 文档。
TextKit 通过 NSTextView 和文本布局管理器等组件处理文本存储、布局和编辑。它不提供本示例使用的数组重排约定。如果某一行还包含编辑器,应将其文本编辑实现与更改行顺序的集合操作分开处理。Apple 的 TextKit 文档。
对于简单的 Mac 列表,应使用一个手动排序的集合,保留项目标识符,并将 onMove 参数直接传给集合的移动方法。需要排查结果或将操作转换为其他模型时,再使用边界计算和测试矩阵。
来源
- onMove(perform:) — Apple
- move(fromOffsets:toOffset:) — Apple
- App — Apple
- ForEach — Apple
- State — Apple
- List — Apple
- EditButton — Apple
- NSTableView.DropOperation — Apple
- TextKit — Apple