Um eine Gruppe gemeinsam mit ihren untergeordneten Elementen zu verschieben, speichere die untergeordneten Elemente im übergeordneten Modell und ordne die übergeordnete Sammlung neu. In dieser Anleitung erstellen wir eine kleine SwiftUI-Liste für den Mac, in der sich jede Gruppe als eine Zeile verschieben lässt und ihre untergeordneten Elemente dabei zugeordnet bleiben.

Die Aufgabe besteht darin, Gruppen innerhalb deiner eigenen App neu anzuordnen: zum Beispiel Group A mit den Elementen Tile A1 und Tile A2 unter Group B zu verschieben. Das Beispiel verwendet synthetische lokale Daten und bietet neben dem SwiftUI-Callback zum Verschieben der Liste explizite Schaltflächen Move Up und Move Down.

Was soll als ein verschiebbares Element gelten?

Die Bewegungseinheit sollte der Einheit in deinen Daten entsprechen. Für diese Benutzeroberfläche enthält das äußere Array Gruppen, und jede Gruppe enthält ihr eigenes Array mit untergeordneten Elementen. Wenn du dieses äußere Array neu anordnest, ändert sich die Reihenfolge der Gruppen, während die untergeordneten Elemente jeder Gruppe und ihre bestehende Reihenfolge erhalten bleiben.

Stelle jede Gruppe als eine zusammengesetzte Listenzeile dar: eine Überschrift, Schaltflächen zum Verschieben und eingerückte Bezeichnungen der untergeordneten Elemente in einem VStack. Füge onMove(perform:) zum äußeren ForEach hinzu. Apple dokumentiert, dass sich die Offsets im Callback auf die zugrunde liegende Sammlung dieser dynamischen Ansicht beziehen; hier ist das das Gruppen-Array. Apples Dokumentation zum Move-Callback.

Gib Gruppen und untergeordneten Elementen stabile IDs. Apples ForEach erfordert identifizierbare Elemente, entweder über Identifiable oder einen expliziten Schlüsselpfad für die ID. In diesem Entwurf gehören die IDs zu den Modellen und bleiben unverändert, wenn sich deren Positionen ändern. Verwende Array-Positionen nicht als Identität von Elementen, die neu angeordnet werden können. Apples Dokumentation zu ForEach.

Eine SwiftUI-Ansicht vom Typ Group ist nicht das unten verwendete Anwendungsmodell. Einen Container zu benennen oder eine Überschrift anzuzeigen, legt nicht fest, welche untergeordneten Datensätze mit ihm verschoben werden sollen. Das gespeicherte Array children des übergeordneten Elements macht diese Beziehung explizit.

Ein vollständiges SwiftUI-Beispiel erstellen

Erstelle in Xcode ein macOS-App-Projekt mit der SwiftUI-Oberfläche und einem Deployment-Ziel ab macOS 12. Ersetze die generierte App-Einstiegsdatei durch das vollständige Programm unten und behalte im Ziel nur eine @main-App-Deklaration. Das Beispiel verwendet absichtlich eine einfache Liste und Textschaltflächen, damit sich das Verschieben leicht nachvollziehen lässt.

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)
    }
}

Das äußere ForEach erstellt für jede Gruppe ein VStack. Das verschachtelte ForEach zeigt die untergeordneten Elemente innerhalb dieser Zeile an; es gibt keinen separaten Handler zum Verschieben untergeordneter Elemente. Beide Schaltflächen zum Verschieben ermitteln anhand der ID die aktuelle Position der Gruppe und rufen dieselbe Änderung auf, die auch von onMove verwendet wird.

Starte die App und klicke neben Group A auf Move Down. Die erwartete Reihenfolge lautet dann Group B, Group A, Group C, wobei Tile A1 und Tile A2 weiterhin unter Group A angezeigt werden. Wenn du neben dieser Gruppe auf Move Up klickst, wird die ursprüngliche Reihenfolge wiederhergestellt. Group C zeigt außerdem, dass sich auch eine Gruppe ohne untergeordnete Elemente verschieben lässt.

Dieses Programm hält die Reihenfolge für die Lebensdauer der aktuellen Ansicht im Speicher; es enthält keine Persistenzschicht. Die Referenzen und die Verschiebelogik wurden überprüft, aber das Programm wurde hier nicht kompiliert, da in der verfügbaren Shell kein Swift-Compiler vorhanden ist. Erstelle den Build und überprüfe die Oberfläche in Xcode auf einem Mac, bevor du das Beispiel integrierst.

Warum wird beim Verschieben nach unten ein anderes Ziel verwendet?

Das an move(fromOffsets:toOffset:) übergebene Ziel ist eine Einfügegrenze in der ursprünglichen Sammlung. Apple definiert sie als den Offset, vor dem die verschobenen Elemente eingefügt werden, innerhalb von 0...count. Wenn du den eigenen Offset des einzelnen Elements oder den darauf folgenden Offset übergibst, ändert sich nichts. Apples Dokumentation zum Verschieben in Sammlungen.

Für die beispielhafte Ausgangsreihenfolge [A, B, C] veranschaulichen diese voneinander unabhängigen Vorgänge den Unterschied:

Beabsichtigtes Ergebnis Quell-Offset An move übergebenes Ziel Ergebnis
A unter B verschieben 0 2 [B, A, C]
B über A verschieben 1 0 [B, A, C]
A ans Ende verschieben 0 3 [B, C, A]
A an seiner Position belassen 0 1 [A, B, C]

Deshalb verwendet die Schaltfläche zum Verschieben nach unten index + 2: Sie fügt das Element vor dem Element hinter dem nächsten Nachbarn oder am Ende ein. Die Schaltfläche zum Verschieben nach oben verwendet index - 1. Übergib Offsets, die direkt von onMove bereitgestellt werden, unverändert an die Sammlungsfunktion, ohne diese schaltflächenspezifische Berechnung erneut anzuwenden.

Die Zielberechnung im Beispiel gilt für das Verschieben eines einzelnen Elements um eine Position. Bei einem Move-Callback mit mehreren Quell-Offsets verwende mit der Sammlungsfunktion den vollständigen IndexSet; laut Dokumentation bleibt dabei die Reihenfolge der verschobenen Elemente erhalten. Apples Dokumentation zum Verschieben in Sammlungen.

Welche Oberfläche erfordert eine andere Implementierung?

Wähle die Implementierung danach aus, wie die Gruppe angezeigt wird und wie Benutzer mit den untergeordneten Elementen interagieren sollen. Das Beispiel mit der zusammengesetzten Zeile eignet sich, wenn eine Gruppe immer als zusammenhängender Block verschoben werden soll. Die untergeordneten Elemente werden absichtlich als Bezeichnungen innerhalb dieses Blocks dargestellt, nicht als eigenständige Listenzeilen.

Anforderung an die Oberfläche Geeigneter Ausgangspunkt Zuständigkeit für das Verschieben
Gruppenüberschrift und untergeordnete Elemente bilden einen verschiebbaren Block Äußeres ForEach mit einer zusammengesetzten Zeile pro Gruppe Übergeordnetes Array neu anordnen
Aufklappbare Hierarchie mit verschachtelten Nachfahren OutlineGroup Modellbewegung für die vorgesehene übergeordnete Sammlung implementieren
AppKit-Sammlung mit einem Abschnitt pro Gruppe NSCollectionView Datenquelle aktualisieren und anschließend die Collection View über die Abschnittsverschiebung informieren
Benutzerdefinierter editierbarer Text innerhalb eines Elements TextKit im Editor des Elements Gruppenreihenfolge im umgebenden Sammlungsmodell verwalten

Für aufklappbare Bäume sind explizite Verschieberegeln erforderlich

Apple beschreibt OutlineGroup als Ansicht identifizierbarer, baumartig strukturierter Daten mit Bedienelementen zum Auf- und Zuklappen von Zweigen. Die Ansicht ist ab macOS 11 verfügbar. Ihr dokumentiertes Darstellungsverhalten legt keine allgemeine Regel für das Neuordnen von Teilbäumen in deiner Anwendung fest. Apples Dokumentation zu OutlineGroup.

Lege bei einem Baum das Ziel genau fest: Ein übergeordnetes Element unter seinen Geschwisterelementen zu verschieben und es innerhalb eines anderen übergeordneten Elements zu verschieben sind unterschiedliche Vorgänge. Eine Neuordnung unter Geschwistern ändert ein übergeordnetes Array. Beim Umhängen muss der Knoten aus seinem bisherigen übergeordneten Element entfernt und in das neue eingefügt werden. Außerdem muss ein Ziel innerhalb der eigenen Nachfahren dieses Knotens abgelehnt werden. Das sind Anwendungsmodellregeln, die sich aus der Baumstruktur ergeben.

Wenn es weiterhin darum geht, ganze Gruppen der obersten Ebene zu verschieben, vermeidet die Implementierung mit zusammengesetzten Zeilen diese zusätzliche Zielregelung. Wenn untergeordnete Elemente unabhängig ausgewählt oder neu angeordnet werden müssen, entwirf diese Interaktionen ausdrücklich, bevor du die Hierarchie in sichtbare Zeilen abflachst.

AppKit unterstützt das Verschieben eines Abschnitts mitsamt seinen enthaltenen Elementen

Wenn deine Oberfläche eine NSCollectionView ist, in der jede Gruppe durch einen Abschnitt dargestellt wird, stellt Apple moveSection(_:toSection:) bereit. Die Dokumentation behandelt ausdrücklich das Neuordnen von Abschnitten und den darin enthaltenen Elementen und verlangt, dass die Datenquelle vor dem Aufruf aktualisiert wird. Die API ist ab macOS 10.11 verfügbar. Apples Dokumentation zum Verschieben von Abschnitten.

Für diesen Ansatz ermittelst du die Indizes des Quellabschnitts und des endgültigen Zielabschnitts, ordnest die Gruppendaten neu und informierst die Collection View über ihre API zum Verschieben von Abschnitten. Halte die SwiftUI-Berechnung der Einfügegrenze getrennt vom Zielabschnittsindex, den AppKit erwartet. Die beiden Methoden definieren ihre Ziele unterschiedlich.

Um eine AppKit-Collection in SwiftUI einzubetten, verwende NSViewRepresentable, Apples dokumentierten Wrapper zum Erstellen und Aktualisieren einer AppKit-Ansicht. Leite Interaktionen mit der Collection über den geeigneten Koordinationsmechanismus zurück an das SwiftUI-Modell und überlasse SwiftUI die äußere Layoutsteuerung der dargestellten Ansicht. Apples Dokumentation zu NSViewRepresentable.

TextKit verwaltet das Textlayout innerhalb eines Elements

Apple definiert NSTextLayoutManager als Manager für benutzerdefiniertes Textlayout und dessen Darstellung; er ist ab macOS 12 verfügbar. Seine Aufgaben betreffen Textcontainer, Elemente und Layoutfragmente. Für diese Sammlungsaufgabe bedeutet das architektonisch, dass die Gruppenreihenfolge im Sammlungsmodell bleibt, auch wenn ein untergeordnetes Element einen benutzerdefinierten TextKit-Editor enthält. Apples Dokumentation zu NSTextLayoutManager.

Wie findest du die Ursache, wenn sich eine Gruppe falsch verschiebt?

Beginne mit dem kleinsten sichtbaren Beispiel und überprüfe das Modell nach einem einzelnen Vorgang. Mit den folgenden Prüfungen lässt sich ein Reihenfolgefehler von einer Abweichung zwischen den angezeigten Zeilen und den zugrunde liegenden Daten unterscheiden.

  1. Nur die Überschrift wird verschoben: Prüfe, ob die untergeordneten Elemente als unabhängige Einträge in einem flachen Array gespeichert sind. Verschiebe das übergeordnete Objekt, dem sie gehören, oder implementiere ausdrücklich das Verschieben eines zusammenhängenden Blocks für die flache Darstellung.
  2. Das Verschieben nach unten bewirkt nichts: Prüfe, ob die Schaltfläche index + 1 übergibt. Bei einem einzelnen Quellelement lässt diese Grenze das Element an seiner Position.
  3. Nach dem Filtern wird die falsche Gruppe verschoben: Prüfe, ob sichtbare Offsets auf das vollständige Array angewendet werden. Ordne sichtbare Gruppen-IDs vor der Änderung dem kanonischen Modell zu und lege fest, wo ein gefiltertes Ziel eingefügt wird.
  4. Die Reihenfolge wird sofort zurückgesetzt: Prüfe, ob das angezeigte Array bei jedem Rendern erneut sortiert wird. Eine manuelle Reihenfolge braucht eine Reihenfolge, die von der Anzeige tatsächlich beibehalten wird.
  5. Ein untergeordnetes Element wird unabhängig verschoben: Prüfe, wo der Move-Handler angebracht ist. In diesem Beispiel gehört er ausschließlich zur äußeren Gruppensammlung.

Prüfe vor der Integration das Verschieben nach oben und unten, beide Enden der Liste, eine einzelne verbleibende Gruppe und eine leere Gruppe. Überprüfe neben dem Erscheinungsbild auch die Modellinvarianten: Dieselben Gruppen-IDs sind weiterhin vorhanden, jede Gruppe enthält weiterhin dieselben IDs ihrer untergeordneten Elemente, und deren Reihenfolge bleibt unverändert. Damit wird direkt geprüft, ob das übergeordnete Element und seine untergeordneten Elemente gemeinsam verschoben werden.

Beginne für eine kompakte Mac-Oberfläche mit einem übergeordneten Modell pro verschiebbarer Zeile und einer gemeinsamen Funktion zum Neuordnen. So kann der Benutzer Group A mit einer einzigen Aktion verschieben, während die untergeordneten Elemente zugeordnet bleiben. Ergänze eine hierarchische oder AppKit-Implementierung, wenn die Oberfläche unabhängige Interaktionen mit untergeordneten Elementen oder abschnittsbasierte Collection-Funktionen erfordert.

Quellen