若要一併移動群組及其子項目,請將子項目存放在父模型中,並重新排序父集合。本指南會建立一個小型 SwiftUI Mac 清單,讓每個群組以單一列移動,子項目則仍附屬於該群組。
此任務是在自有 App 中重新排列群組:例如,將包含 Tile A1 和 Tile A2 的 Group A 移至 Group B 下方。範例使用合成的本機資料,並提供明確的 Move Up 和 Move Down 按鈕,以及 SwiftUI 的清單移動回呼。
哪些內容應視為一個可移動項目?
讓移動單位與資料中的單位一致。對此介面而言,外層陣列包含群組,而每個群組各自包含一個子項目陣列。重新排序外層陣列會改變群組順序,同時保留各群組的子項目及其原有順序。
將每個群組表示為一個複合清單列:在 VStack 內放入標題、移動按鈕,以及縮排的子項目標籤。將 onMove(perform:) 附加至外層 ForEach。Apple 說明,回呼中的位移量是相對於該動態檢視所依據的集合;此處的集合就是群組陣列。Apple 的移動回呼文件。
為群組和子項目指定穩定識別碼。Apple 的 ForEach 要求元素具有識別資訊,可透過 Identifiable 或明確的識別碼鍵路徑提供。在此設計中,識別碼屬於模型,且位置改變時識別碼保持不變。避免以陣列位置作為可重新排序內容的識別資訊。Apple 的 ForEach 文件。
SwiftUI 的 Group 檢視並非下方使用的應用程式模型。命名容器或顯示標題,並不能明確指出哪些子記錄應隨其移動。父項目所儲存的 children 陣列會明確表達這種關係。
建立完整的 SwiftUI 範例
在 Xcode 中建立 macOS App 專案,選擇 SwiftUI 介面,並將部署目標設為 macOS 12 或更新版本。將產生的 App 進入點檔案替換為下方完整程式,並確保目標中只保留一個 @main App 宣告。此範例刻意使用簡單的清單和文字按鈕,讓移動行為容易檢視。
import Foundation
import SwiftUI
private struct DemoItem: Identifiable {
let id: String
let title: String
}
private struct DemoGroup: Identifiable {
let id: String
let title: String
let children: [DemoItem]
}
@main
struct GroupMoveDemoApp: App {
var body: some Scene {
WindowGroup {
GroupCollectionView()
}
}
}
private struct GroupCollectionView: View {
@State private var groups: [DemoGroup] = [
DemoGroup(
id: "group-a", title: "Group A",
children: [
DemoItem(id: "a1", title: "Tile A1"),
DemoItem(id: "a2", title: "Tile A2")
]
),
DemoGroup(
id: "group-b", title: "Group B",
children: [
DemoItem(id: "b1", title: "Tile B1")
]
),
DemoGroup(
id: "group-c", title: "Group C",
children: []
)
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Arrange Groups")
.font(.title2)
.padding(.horizontal)
List {
ForEach(groups) { group in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(group.title).font(.headline)
Spacer()
Button("Move Up") {
moveOne(id: group.id, down: false)
}
.disabled(group.id == groups.first?.id)
Button("Move Down") {
moveOne(id: group.id, down: true)
}
.disabled(group.id == groups.last?.id)
}
.buttonStyle(BorderlessButtonStyle())
ForEach(group.children) { child in
Text(child.title)
.padding(.leading, 16)
}
}
.padding(.vertical, 8)
}
.onMove(perform: moveGroups)
}
}
.padding(.vertical)
.frame(minWidth: 520, minHeight: 320)
}
private func moveGroups(from source: IndexSet, to destination: Int) {
withAnimation {
groups.move(fromOffsets: source, toOffset: destination)
}
}
private func moveOne(id: String, down: Bool) {
guard let index = groups.firstIndex(where: { $0.id == id }) else {
return
}
let neighbor = down ? index + 1 : index - 1
guard groups.indices.contains(neighbor) else {
return
}
let destination = down ? index + 2 : index - 1
moveGroups(from: IndexSet(integer: index), to: destination)
}
}
外層 ForEach 會為每個群組產生一個 VStack。巢狀的 ForEach 會在該列中顯示子項目;沒有另外設定子項目的移動處理器。兩個移動按鈕都會依 ID 找出群組目前的位置,並呼叫與 onMove 相同的變更函式。
執行 App,並點選 Group A 旁的 Move Down。預期順序會變成 Group B、Group A、Group C,而 Tile A1 和 Tile A2 仍會顯示在 Group A 下方。點選該群組旁的 Move Up 可還原初始順序。Group C 則示範沒有子項目的群組仍可移動。
此程式會在目前檢視的生命週期內將順序保存在記憶體中;它不包含持久化層。本文已檢視參考資料和移動邏輯,但由於目前可用的 Shell 沒有 Swift 編譯器,因此未在此處編譯。整合前,請在 Mac 上使用 Xcode 建置並檢查介面。
為什麼向下移動使用不同的目的位移量?
傳給 move(fromOffsets:toOffset:) 的目的位移量,是原始集合中的插入邊界。Apple 將其定義為要在其前方插入移動元素的位移量,範圍為 0...count。傳入單一元素本身的位移量或緊接其後的位移量都不會改變順序。Apple 的集合移動文件。
以下以示例初始順序 [A, B, C] 說明這些彼此獨立的操作:
| 預期結果 | 來源位移量 | 傳給 move 的目的位移量 |
結果 |
|---|---|---|---|
將 A 移至 B 下方 |
0 |
2 |
[B, A, C] |
將 B 移至 A 上方 |
1 |
0 |
[B, A, C] |
將 A 移至末尾 |
0 |
3 |
[B, C, A] |
讓 A 留在原位 |
0 |
1 |
[A, B, C] |
因此,向下按鈕使用 index + 2:如此會在下一個相鄰項目之後的元素前插入,或插入至末尾。向上按鈕則使用 index - 1。對於由 onMove 直接提供的位移量,請原樣傳給集合方法,不要再套用此按鈕專用的計算。
範例中的目的位移量計算適用於單一相鄰移動。若移動回呼包含多個來源位移量,請將完整的 IndexSet 傳給集合方法;該方法文件說明會保留移動元素的順序。Apple 的集合移動文件。
哪種介面需要不同的實作方式?
請依群組的呈現方式,以及使用者需要如何與其子項目互動,選擇實作方式。當群組應始終以一個區塊移動時,複合列範例很適合。此範例刻意將子項目顯示為區塊內的標籤,而非獨立的清單列。
| 介面需求 | 合適的起點 | 移動時應處理的事項 |
|---|---|---|
| 群組標題與子項目組成一個可移動區塊 | 外層 ForEach,每個群組對應一個複合列 |
重新排序父陣列 |
| 可展開且含有巢狀後代的階層 | OutlineGroup |
為預期的父集合實作模型移動邏輯 |
| 每個群組各自為一個區段的 AppKit 集合 | NSCollectionView |
更新資料來源,再通知集合檢視移動區段 |
| 項目內的自訂可編輯文字 | 項目編輯器中的 TextKit | 在周圍的集合模型中處理群組排序 |
可展開樹狀結構需要明確的移動規則
Apple 將 OutlineGroup 描述為用來呈現具識別資訊的樹狀結構資料的檢視,並提供用於展開與摺疊分支的顯示控制項。macOS 11 或更新版本可使用此元件。其文件所述的呈現行為,並未定義應用程式通用的子樹重新排序政策。Apple 的 OutlineGroup 文件。
對於樹狀結構,請明確定義目的地:在同層項目間移動父項目,與將其移入另一個父項目,是不同的操作。同層重新排序會變更一個父陣列。重新指定父項目則需要從舊父項目中移除節點、插入新父項目,並拒絕將目的地設在該節點自己的後代之中。這些是根據樹狀結構衍生出的應用程式模型規則。
若任務仍是移動整個頂層群組,複合列實作可避免額外制定目的地政策。若子項目需要獨立選取或重新排序,請先明確設計這些互動,再將階層攤平成可見列。
AppKit 支援連同所含項目一起移動區段
若介面採用 NSCollectionView,並以區段代表各群組,Apple 提供了 moveSection(_:toSection:)。其文件明確涵蓋重新組織區段及其所含項目,並要求在呼叫此方法前更新資料來源。macOS 10.11 或更新版本可使用此 API。Apple 的區段移動文件。
使用此方式時,請取得來源區段索引和最終目的地區段索引,重新排序群組資料,然後使用集合檢視的區段移動 API 通知它。SwiftUI 的插入邊界計算應與 AppKit 所需的目的地區段索引分開處理。這兩種方法對目的地的定義不同。
若要將 AppKit 集合嵌入 SwiftUI,請使用 NSViewRepresentable;Apple 將其記載為建立及更新 AppKit 檢視的包裝器。透過適當的協調機制,將集合互動傳回 SwiftUI 模型,並讓 SwiftUI 控制所表示檢視的外層版面配置。Apple 的 NSViewRepresentable 文件。
TextKit 負責項目內的文字版面配置
Apple 將 NSTextLayoutManager 定義為用於自訂文字版面配置及呈現的管理器,macOS 12 或更新版本可使用。其職責涉及文字容器、元素及版面配置片段。對此集合任務而言,架構上的做法是將群組排序保留在集合模型中,即使子項目內含自訂的 TextKit 編輯器也一樣。Apple 的 NSTextLayoutManager 文件。
如何診斷群組移動錯誤?
從最精簡的可見範例開始,並在執行一次操作後檢查模型。以下檢查可協助區分排序錯誤,以及顯示列與底層資料不一致的情況。
- **只有標題移動:**檢查子項目是否以無關項目的形式存放在扁平陣列中。請移動擁有子項目的父物件,或為扁平表示法明確實作區塊移動。
- **向下移動沒有作用:**檢查按鈕是否傳入
index + 1。對單一來源項目而言,該邊界會讓項目留在原位。 - **篩選後移動了錯誤的群組:**檢查是否將可見項目的位移量套用至完整陣列。變更前,請將可見群組 ID 對應至正規模型,並定義篩選後的目的地應位於何處。
- **順序立即還原:**檢查每次呈現時是否都再次排序顯示陣列。手動排序需要有顯示內容實際保留的順序。
- **子項目可獨立移動:**檢查移動處理器的附加位置。在此範例中,它只屬於外層群組集合。
整合前,請檢查向上和向下移動、清單兩端、只剩一個群組的情況,以及空群組。除檢查外觀外,也請驗證模型不變條件:相同的群組 ID 仍然存在、每個群組保留相同的子項目 ID,且子項目順序不變。這些檢查會直接驗證父項目與子項目一同移動的需求。
對於精簡的 Mac 介面,請先為每個可移動列建立一個父模型,並使用一個共用的重新排序函式。如此,使用者只需一個動作就能移動 Group A,其子項目也會保持附屬於該群組。若介面需要子項目獨立互動或以區段為基礎的集合行為,再加入階層式或 AppKit 實作。
來源
- onMove(perform:) — Apple
- ForEach — Apple
- move(fromOffsets:toOffset:) — Apple
- OutlineGroup — Apple
- moveSection(_:toSection:) — Apple
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple