若要在 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 版本上確認實際拖曳互動。
為什麼列仍可能移動錯誤?
先檢查集合邊界,再檢查識別碼與修改方式。這個順序有助於區分目的地索引計算問題,以及顯示的列與儲存資料不一致的問題。
- 檢查傳給
ForEach的集合。 如果顯示的是篩選後的陣列,其偏移值描述的就是該篩選後陣列。例如,可見列B, D的偏移值是0, 1;若將來源0套用到完整陣列,就會移動A。這個結果源自 Apple 所記載的回呼契約:偏移值相對於集合。Apple 的onMove(perform:)文件。 - 檢查呈現時是否再次排序資料。 如果每次重繪都依標籤排序,修改底層陣列的順序就無法建立持久的手動顯示順序。在該檢視啟用重新排序前,先決定手動排序政策。
- 檢查識別碼。 移動項目時保留各自的識別碼。呈現期間不要重新產生識別碼,也不要用會改變的位置作為可移動項目的識別方式。Apple 的
ForEach文件。 - 檢查是否重複處理。 移除手動減去目的地索引的程式碼,再確認處理常式只對權威集合套用一次移動操作。第二次移動會作用在已變更的順序上。
對於篩選或分區介面,轉換索引前先定義預期行為。決定移動操作是只重新排列可見項目、變更整個集合,還是將項目移至其他分區。將可見項目對應到模型識別碼,並依該政策解析目的地。單一數字偏移值無法表達所有這些選擇。
讓回呼偏移值始終對應到其描述的集合快照。如果 App 延後操作期間,其他編輯已變更陣列,就應重新解析預期的項目與目的地,而不是重播過期的位置。本範例會立即修改本機陣列,因此不會遇到這種複雜情況。
AppKit 或 TextKit 會改變目的地規則嗎?
對於已使用 NSTableView 的 App,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