Aby zmienić kolejność listy w aplikacji na Maca, przekaż źródło i cel onMove SwiftUI bezpośrednio do move(fromOffsets:toOffset:). Cel wskazuje granicę w oryginalnej kolekcji, więc ręczne odjęcie jedynki przy przenoszeniu elementu w dół daje nieprawidłowy wynik.

W tym przewodniku zbudujemy niewielką natywną listę macOS z przykładowymi etykietami, wyjaśnimy obliczanie indeksu docelowego i przedstawimy zestaw testów pomagający diagnozować błędnie przeniesione wiersze. Zakres obejmuje zmianę kolejności elementów na liście we własnej aplikacji.

Co oznacza cel onMove?

Apple definiuje dwa argumenty onMove(perform:) jako przesunięcia względem kolekcji bazowej dynamicznego widoku. Pierwszy argument to IndexSet przesunięć źródłowych, a drugi to przesunięcie docelowe. Pozycje te odnoszą się do kolekcji używanej przez konkretne ForEach. Dokumentacja Apple dotycząca onMove(perform:).

W przypadku move(fromOffsets:toOffset:) Apple definiuje cel jako przesunięcie, przed którym należy wstawić przenoszone elementy; count może również oznaczać koniec. Wyobraź sobie granicę między elementami oryginalnej kolekcji. Dokumentacja Apple dotycząca move(fromOffsets:toOffset:).

Rozważmy oryginalną kolejność A, B, C, D, E. Przesunięcia elementów to 0, 1, 2, 3, 4, natomiast granice wstawiania rozciągają się od 0 do 5. Granica 4 znajduje się bezpośrednio przed E; granica 5 leży za ostatnim elementem.

Przeniesienie B ze źródłowego przesunięcia 1 do celu 4 daje więc kolejność A, C, D, B, E. Przeniesiony element trafia na indeks 3, mimo że argument docelowy miał wartość 4. Obie wartości są pozycjami liczonymi od zera, ale opisują różne etapy operacji.

To rozróżnienie wyjaśnia częsty błąd przy przenoszeniu elementu w dół. Kod, który przed wywołaniem metody przenoszącej kolekcji zmienia cel 4 na 3, wskazuje inną granicę i umieszcza B przed D. Korzystając z metody przenoszącej Apple, przekaż wartości z wywołania zwrotnego bez zmian.

Zbuduj kompletną listę macOS ze zmienianą kolejnością

Użyj natywnego projektu aplikacji macOS SwiftUI w Xcode. Poniższy kompletny przykład wymaga macOS 11 lub nowszego, ponieważ korzysta z cyklu życia SwiftUI App; Apple podaje, że samo onMove jest dostępne od macOS 10.15. Zastąp wygenerowane deklaracje aplikacji i widoku zawartości tym kodem, pozostawiając w celu dokładnie jeden punkt wejścia @main. Dokumentacja Apple dotycząca App, dokumentacja Apple dotycząca 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 wyświetla tę samą tablicę, którą modyfikuje procedura obsługi. Każdy element ma stały identyfikator, niezależny od bieżącej pozycji. Apple wymaga, aby elementy ForEach były identyfikowalne albo aby podać jawny klucz tożsamości; zachowanie identyfikatora podczas przenoszenia pozwala widokowi nadal reprezentować ten sam element. Dokumentacja Apple dotycząca ForEach.

Tablica jest lokalnym stanem widoku. Jej zmiana powoduje aktualizację zależnych widoków SwiftUI, w tym listy i wyświetlanej kolejności. Ten przykład przechowuje kolejność w pamięci i nie zawiera mechanizmu zapisu danych. Dokumentacja Apple dotycząca State.

Uruchom aplikację, przeciągnij B do granicy bezpośrednio przed E i sprawdź zarówno kolejność, jak i wyświetlane informacje o wywołaniu zwrotnym. Przed wypróbowaniem kolejnego scenariusza użyj przycisku Reset. Jawne powiązanie zaznaczenia umożliwia także wybór wielu wierszy; Apple opisuje zbiory zaznaczeń i zaznaczanie na Macu bez wchodzenia w tryb edycji. Dokumentacja Apple dotycząca List.

Nie dodawaj EditButton() do tego natywnego przykładu macOS. Obecne informacje Apple o dostępności obejmują iOS i Mac Catalyst, ale nie natywny macOS. Nie kopiuj do celu Mac mobilnych kontrolek edycji z samouczka bez sprawdzenia ich dostępności. Dokumentacja Apple dotycząca EditButton.

Jak obliczyć końcowy indeks wstawienia?

To obliczenie jest potrzebne tylko wtedy, gdy samodzielnie implementujesz usuwanie i wstawianie albo przekształcasz operację przeniesienia na inną operację modelu. Jest to objaśniający model wynikający z udokumentowanej przez Apple semantyki wstawiania, a nie dodatkowa korekta stosowana przed wywołaniem wbudowanej metody przenoszącej.

Obliczenie wygląda tak:

indeks wstawienia po usunięciu = pierwotny cel
                                - liczba przesunięć źródłowych mniejszych od celu

Dla źródła {1} i celu 4 jeden usuwany element znajduje się pierwotnie przed granicą. Zredukowana tablica to A, C, D, E, a indeks wstawienia wynosi 4 - 1 = 3. Wstawienie tam B daje A, C, D, B, E.

W przypadku wielu źródeł pojedyncza korekta w dół nie wystarczy. Dla źródeł {1, 3} i celu 5 przenoszone elementy to B, D; pozostała tablica to A, C, E. Dwa przesunięcia źródłowe znajdują się przed celem 5, więc indeks wstawienia wynosi 3. Wynik to A, C, E, B, D.

Własna implementacja musi najpierw zebrać przenoszone elementy w ich oryginalnej kolejności, a następnie usunąć oryginały tak, by wcześniejsze usunięcia nie unieważniły późniejszych pozycji źródłowych. Potem może obliczyć skorygowaną pozycję wstawienia i wstawić cały zebrany blok. W przypadku listy opartej na tablicy istniejąca metoda Apple pozwala uniknąć samodzielnej implementacji tych kroków.

Które przypadki brzegowe warto przetestować?

Poniższe przypadki to przykładowe obliczenia dla oryginalnej kolejności A, B, C, D, E. Przed każdym przypadkiem przywróć tę kolejność. Ostatnia kolumna pokazuje oczekiwany wynik modelu, niezależnie od wyglądu wskaźnika upuszczania.

Przesunięcia źródłowe Pierwotny cel Indeks wstawienia po usunięciu Oczekiwana kolejność
{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

Dwa przypadki bez zmiany kolejności dla pojedynczego elementu są istotne: wstawienie elementu na jego własnej granicy lub na następnej granicy nie zmienia jego kolejności. Apple wyraźnie dokumentuje to zachowanie. Granica końcowa również jest prawidłowa, mimo że nie można użyć jej do indeksowania elementu. Dokumentacja Apple dotycząca move(fromOffsets:toOffset:).

Model arytmetyczny dla tych siedmiu przypadków sprawdzono za pomocą niewielkiego, deterministycznego obliczenia w Pythonie. Kod SwiftUI został przejrzany, ale nie został skompilowany ani uruchomiony, ponieważ środowisko nie udostępniało narzędzi Swift ani systemu macOS. Sprawdź rzeczywiste przeciąganie na docelowej wersji macOS.

Dlaczego wiersze nadal mogą przenosić się nieprawidłowo?

Zacznij od granicy kolekcji, a następnie sprawdź tożsamość elementów i sposób modyfikacji danych. Ta kolejność pozwala odróżnić problemy z obliczaniem celu od rozbieżności między wyświetlanymi wierszami a przechowywanymi danymi.

  1. Sprawdź kolekcję przekazaną do ForEach. Jeśli wyświetla przefiltrowaną tablicę, jej przesunięcia odnoszą się do tej przefiltrowanej tablicy. Na przykład widoczne wiersze B, D mają przesunięcia 0, 1; zastosowanie źródła 0 do pełnej tablicy przeniosłoby A. Wynika to z udokumentowanej przez Apple umowy, według której wywołanie zwrotne odnosi się do kolekcji. Dokumentacja Apple dotycząca onMove(perform:).
  2. Sprawdź, czy podczas renderowania dane nie są ponownie sortowane. Jeśli przy każdym odświeżeniu etykiety są sortowane, zmiana kolejności tablicy bazowej nie zapewni trwałej ręcznej kolejności wyświetlania. Przed włączeniem zmiany kolejności w tym widoku wybierz sposób ręcznego ustalania kolejności.
  3. Sprawdź identyfikatory. Zachowaj identyfikator każdego elementu podczas jego przenoszenia. Nie generuj identyfikatorów ponownie podczas renderowania i nie używaj zmieniających się pozycji jako tożsamości przenoszonych elementów. Dokumentacja Apple dotycząca ForEach.
  4. Sprawdź, czy operacja nie jest wykonywana podwójnie. Usuń ręczne odejmowanie od celu, a następnie potwierdź, że procedura obsługi raz stosuje przeniesienie do kolekcji będącej źródłem prawdy. Druga operacja przeniesienia działa na już zmienionej kolejności.

W przypadku interfejsów z filtrowaniem lub sekcjami określ zamierzone zachowanie przed przekształceniem indeksów. Zdecyduj, czy przeniesienie zmienia kolejność tylko widocznych elementów, całej kolekcji, czy przenosi element między sekcjami. Powiąż widoczne elementy z tożsamościami modelu i wyznacz cel zgodnie z tą polityką. Sam indeks liczbowy nie wyraża wszystkich tych decyzji.

Powiąż przesunięcia z wywołania zwrotnego z migawką kolekcji, której dotyczą. Jeśli aplikacja opóźnia operację, a w międzyczasie inne zmiany modyfikują tablicę, ponownie ustal zamierzone elementy i cel zamiast odtwarzać nieaktualne pozycje. Przykład unika tej komplikacji, natychmiast modyfikując lokalną tablicę.

Czy AppKit lub TextKit zmieniają reguły dotyczące celu?

W aplikacji korzystającej już z NSTableView AppKit ma własną umowę dotyczącą upuszczania. NSTableView.DropOperation.above oznacza wstawienie nad wierszem, a .on — upuszczenie na wierszu. Apple dokumentuje upuszczenie poniżej ostatniego wiersza jako wiersz n z operacją .above, gdzie n oznacza liczbę wierszy. Zinterpretuj zarówno wiersz, jak i operację, zanim przekształcisz upuszczenie AppKit na przeniesienie w modelu. Dokumentacja Apple dotycząca NSTableView.DropOperation.

TextKit obsługuje przechowywanie, układ i edycję tekstu za pomocą komponentów takich jak NSTextView i menedżery układu tekstu. Nie definiuje umowy dotyczącej zmiany kolejności tablicy użytej w tym przykładzie. Jeśli wiersz zawiera również edytor, oddziel implementację edycji tekstu od operacji kolekcji zmieniającej kolejność wierszy. Dokumentacja Apple dotycząca TextKit.

W przypadku prostej listy na Macu używaj jednej kolekcji z ręcznie ustalaną kolejnością, zachowuj identyfikatory elementów i przekazuj argumenty onMove bezpośrednio do metody przenoszącej kolekcji. Gdy trzeba zdiagnozować wynik albo przekształcić operację na inną postać modelu, skorzystaj z obliczania granicy i zestawu testów.

Źródła