要在 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 版本上验证实际拖动交互。

为什么行仍然会移动到错误位置?

先检查集合边界,再检查身份和修改方式。按此顺序排查,可以区分目标索引计算问题与显示行和存储数据不一致的问题。

  1. 检查传给 ForEach 的集合。 如果它显示的是经过筛选的数组,其偏移量描述的就是该筛选数组。例如,可见行 B, D 的偏移量是 0, 1;将源位置 0 应用到完整数组,会移动 A。这是 Apple 文档所述集合相对回调约定的直接结果。Apple 的 onMove(perform:) 文档。
  2. 检查渲染时是否再次对数据排序。 如果每次重绘都会按标签排序,修改底层数组的顺序就无法建立持久的手动显示顺序。在该视图启用重排前,应先确定手动排序策略。
  3. 检查标识符。 移动项目时保留其标识符。不要在渲染时重新生成标识符,也不要用会变化的位置作为可移动项目的身份。Apple 的 ForEach 文档。
  4. 检查是否重复执行。 移除手动调整目标位置的操作,然后确认处理程序只对权威集合应用一次移动。第二次移动会作用于已经改变的顺序。

对于经过筛选或分区的界面,应先确定预期行为,再转换索引。决定移动操作是只调整可见项目、改变整个集合,还是将项目移到另一个分区。将可见项目映射到模型身份,并根据该策略确定目标位置。单个数字偏移量无法表达所有这些选择。

回调偏移量应始终与其所描述的集合快照对应。如果应用延迟执行操作期间数组发生了其他变化,应重新解析预期项目和目标位置,而不是重放过时的位置。本示例立即修改本地数组,因此不会遇到这一问题。

AppKit 或 TextKit 会改变目标位置规则吗?

如果应用已在使用 NSTableView,AppKit 有自己的放置约定。NSTableView.DropOperation.above 表示放置在某行上方,.on 表示放置在该行上。Apple 将最后一行下方的放置定义为行 n,操作为 .above,其中 n 是行数。将 AppKit 放置转换为模型移动操作之前,应先解释行号和操作类型。Apple 的 NSTableView.DropOperation 文档。

TextKit 通过 NSTextView 和文本布局管理器等组件处理文本存储、布局和编辑。它不提供本示例使用的数组重排约定。如果某一行还包含编辑器,应将其文本编辑实现与更改行顺序的集合操作分开处理。Apple 的 TextKit 文档。

对于简单的 Mac 列表,应使用一个手动排序的集合,保留项目标识符,并将 onMove 参数直接传给集合的移动方法。需要排查结果或将操作转换为其他模型时,再使用边界计算和测试矩阵。

来源