若要在 Mac App 中重新排序清單,請將 SwiftUI 的 onMove 來源與目的地直接傳給 move(fromOffsets:toOffset:)。目的地標示的是原始集合中的邊界,因此向下移動時手動減去 1 會導致錯誤結果。

本指南會使用合成標籤建立一個小型原生 macOS 清單,說明目的地的索引計算方式,並提供用於診斷列位置錯誤的測試矩陣。範圍僅限於在 App 自己的清單中重新排序項目。

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 App 專案。以下完整範例以 macOS 11 或更新版本為目標,因為它使用 SwiftUI 的 App 生命週期;Apple 列出的 onMove 本身則從 macOS 10.15 起可用。請以這段程式碼取代產生的 App 與內容檢視宣告,並確保目標中只有一個 @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 文件。

執行 App,將 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. 檢查是否重複處理。 移除手動減去目的地索引的程式碼,再確認處理常式只對權威集合套用一次移動操作。第二次移動會作用在已變更的順序上。

對於篩選或分區介面,轉換索引前先定義預期行為。決定移動操作是只重新排列可見項目、變更整個集合,還是將項目移至其他分區。將可見項目對應到模型識別碼,並依該政策解析目的地。單一數字偏移值無法表達所有這些選擇。

讓回呼偏移值始終對應到其描述的集合快照。如果 App 延後操作期間,其他編輯已變更陣列,就應重新解析預期的項目與目的地,而不是重播過期的位置。本範例會立即修改本機陣列,因此不會遇到這種複雜情況。

AppKit 或 TextKit 會改變目的地規則嗎?

對於已使用 NSTableView 的 App,AppKit 有自己的放置契約。NSTableView.DropOperation.above 表示插入到某列上方,而 .on 表示放到該列上。Apple 說明,放置到最後一列下方時,列索引是 n,操作為 .above,其中 n 是列數。將 AppKit 的放置操作轉換成模型移動前,請同時解讀列索引與操作類型。Apple 的 NSTableView.DropOperation 文件。

TextKit 透過 NSTextView 和文字版面配置管理器等元件處理文字儲存、版面配置與編輯。它不提供本範例使用的陣列重新排序契約。如果某列也包含編輯器,請將文字編輯實作與變更列順序的集合操作分開處理。Apple 的 TextKit 文件。

對於簡單的 Mac 清單,請使用單一手動排序的集合、保留項目識別碼,並將 onMove 引數直接傳給集合的移動方法。若要診斷結果或將操作轉換成不同模型,請使用邊界計算方式與測試矩陣。

資料來源