若要一併移動群組及其子項目,請將子項目存放在父模型中,並重新排序父集合。本指南會建立一個小型 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 文件。

如何診斷群組移動錯誤?

從最精簡的可見範例開始,並在執行一次操作後檢查模型。以下檢查可協助區分排序錯誤,以及顯示列與底層資料不一致的情況。

  1. **只有標題移動:**檢查子項目是否以無關項目的形式存放在扁平陣列中。請移動擁有子項目的父物件,或為扁平表示法明確實作區塊移動。
  2. **向下移動沒有作用:**檢查按鈕是否傳入 index + 1。對單一來源項目而言,該邊界會讓項目留在原位。
  3. **篩選後移動了錯誤的群組:**檢查是否將可見項目的位移量套用至完整陣列。變更前,請將可見群組 ID 對應至正規模型,並定義篩選後的目的地應位於何處。
  4. **順序立即還原:**檢查每次呈現時是否都再次排序顯示陣列。手動排序需要有顯示內容實際保留的順序。
  5. **子項目可獨立移動:**檢查移動處理器的附加位置。在此範例中,它只屬於外層群組集合。

整合前,請檢查向上和向下移動、清單兩端、只剩一個群組的情況,以及空群組。除檢查外觀外,也請驗證模型不變條件:相同的群組 ID 仍然存在、每個群組保留相同的子項目 ID,且子項目順序不變。這些檢查會直接驗證父項目與子項目一同移動的需求。

對於精簡的 Mac 介面,請先為每個可移動列建立一個父模型,並使用一個共用的重新排序函式。如此,使用者只需一個動作就能移動 Group A,其子項目也會保持附屬於該群組。若介面需要子項目獨立互動或以區段為基礎的集合行為,再加入階層式或 AppKit 實作。

來源