Aby edytować przefiltrowaną listę SwiftUI bez zmieniania niewłaściwego elementu, zachowaj jedną oryginalną tablicę i powiąż każdy widoczny wiersz z jej elementem za pomocą stabilnego identyfikatora. Ten przykład dla macOS pozwala filtrować, zmieniać nazwy, przełączać i usuwać syntetyczne etykiety we własnej aplikacji.

Dlaczego samo filtrowanie nie tworzy edytowalnych wierszy

Metoda filter(_:) języka Swift zwraca nową kolekcję zawierającą pasujące elementy w ich oryginalnej kolejności. Nie tworzy zapisywalnego powiązania między tą kolekcją a jej źródłem. W przypadku tablicy struktur zmiana elementu w osobno zapisanej przefiltrowanej tablicy nie aktualizuje oryginalnej tablicy. Wnioski te wynikają z udokumentowanego działania metody filter(_:) oraz semantyki wartości struktur w języku Swift.

Załóżmy na przykład, że oryginalna tablica zawiera elementy Grid, Tile i Ring, ale filtr wyświetla tylko Ring. Jego widoczna pozycja to zero, a pozycja w oryginalnej tablicy to dwa. Użycie widocznej pozycji do zapisu w oryginalnej tablicy zmieniłoby Grid. Sortowanie wyświetlonych wyników może doprowadzić do takiej samej rozbieżności.

Powiązanie zapewnia brakujące połączenie do odczytu i zapisu. Apple opisuje Binding jako połączenie z danymi należącymi do innego miejsca i udostępnia init(get:set:) do definiowania niestandardowego dostępu. W tym przykładzie obie domknięcia przy każdym wywołaniu wyszukują oryginalny element po identyfikatorze. Przefiltrowana kolekcja określa, które wiersze są wyświetlane; zmiany zapisują się w oryginalnej tablicy. Zobacz Binding i init(get:set:).

Wybierz stabilną tożsamość, zanim zbudujesz edytor

Każdy element potrzebuje identyfikatora, który nie zmienia się wraz ze zmianą jego nazwy, ustawienia uwzględnienia ani pozycji w tablicy. Protokół Identifiable firmy Apple opisuje stabilną tożsamość obiektu, a ForEach korzysta z danych identyfikowalnych lub jawnego identyfikatora, aby rozróżniać tworzone widoki. Zobacz Identifiable i ForEach.

Przykład tworzy każdy UUID raz, we właściwości przechowywanej let id. Zmiana nazwy etykiety nie narusza jej identyfikatora. Nie wyprowadzaj identyfikatora z edytowalnej nazwy ani nie generuj nowego UUID przy każdym odczycie identyfikatora. Zadbaj też o to, by różne elementy miały różne identyfikatory: wyszukiwanie celowo znajduje pierwszy pasujący identyfikator, więc duplikaty powodowałyby niejednoznaczność miejsca zapisu.

Skorzystaj z poniższej tabeli, aby wybrać przepływ danych:

Wymaganie interfejsu Sposób użycia powiązania
Wyświetlanie niezmienionej kolekcji wartości Przekaż wartości do wierszy tylko do odczytu.
Bezpośrednie edytowanie oryginalnej kolekcji W razie potrzeby użyj obsługi powiązań kolekcji w SwiftUI.
Edytowanie wyników, których pozycje różnią się od pozycji źródłowych Wyszukaj oryginalny element po stabilnym identyfikatorze.
Utrzymanie otwartego edytora po tym, jak jego element zniknie z wyników Zapewnij edytorowi własny wybór i zasady cyklu życia.

Dalsza część implementuje trzeci przypadek. Określa też, co dzieje się, gdy wiersz znika, dzięki czemu brakujący element nigdy nie prowadzi do indeksowania tablicy bez sprawdzenia.

Zbuduj kompletny przykład dla macOS

Utwórz w Xcode projekt aplikacji macOS korzystającej z SwiftUI i ustaw w tym przykładzie docelową wersję macOS 13 lub nowszą. Zastąp wygenerowane deklaracje aplikacji i widoku zawartości poniższym pojedynczym plikiem. Zachowaj dokładnie jeden punkt wejścia @main; dokumentacja App firmy Apple opisuje to wymaganie oraz używaną tu strukturę WindowGroup.

Oryginalna tablica należy do rodzica i jest przechowywana w prywatnym @State; każdy widok potomny otrzymuje @Binding. Zgodnie z udokumentowanym przez Apple wzorcem udostępniania stanu widokom podrzędnym. Przykład zawiera wyłącznie lokalne syntetyczne etykiety i przechowuje dane w pamięci.

import SwiftUI
import Foundation

private struct LabelItem: Identifiable, Sendable {
    let id = UUID()
    var name: String
    var isIncluded: Bool
}

@main
struct FilteredLabelsApp: App {
    var body: some Scene {
        WindowGroup {
            LabelEditor()
        }
    }
}

@MainActor
private struct LabelEditor: View {
    @State private var items = [
        LabelItem(name: "Grid", isIncluded: true),
        LabelItem(name: "Tile", isIncluded: false),
        LabelItem(name: "Ring", isIncluded: true)
    ]
    @State private var query = ""
    @State private var includedOnly = false
    @State private var notice = ""

    private var visibleItems: [LabelItem] {
        items.filter { item in
            (!includedOnly || item.isIncluded) &&
            (query.isEmpty ||
             item.name.localizedCaseInsensitiveContains(query))
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Filter labels", text: $query)
            Toggle("Included only", isOn: $includedOnly)

            Button("Reverse original order") {
                items.reverse()
            }

            List {
                ForEach(visibleItems) { snapshot in
                    LabelRow(item: itemBinding(for: snapshot)) {
                        items.removeAll { $0.id == snapshot.id }
                    }
                }
            }

            Text("Original array:")
                .font(.headline)
            Text(items.map(\.name).joined(separator: " · "))
            Text(notice)
        }
        .padding()
        .frame(minWidth: 540, minHeight: 360)
    }

    private func itemBinding(
        for snapshot: LabelItem
    ) -> Binding<LabelItem> {
        let id = snapshot.id

        return Binding(
            get: {
                items.first { $0.id == id } ?? snapshot
            },
            set: { updated in
                guard let index = items.firstIndex(where: {
                    $0.id == id
                }) else {
                    notice = "Edit skipped: label was removed."
                    return
                }

                items[index].name = updated.name
                items[index].isIncluded = updated.isIncluded
                notice = ""
            }
        )
    }
}

@MainActor
private struct LabelRow: View {
    @Binding var item: LabelItem
    let remove: () -> Void

    var body: some View {
        HStack {
            TextField("Label name", text: $item.name)
            Toggle("Included", isOn: $item.isIncluded)
            Button("Remove", action: remove)
        }
    }
}

To kompletny przykład aplikacji, a nie fragment do wklejenia wewnątrz istniejącego widoku. Sprawdzono go pod kątem zgodności z pobraną dokumentacją API, ale nie został tu skompilowany, ponieważ to środowisko nie ma łańcucha narzędzi Swift ani SDK macOS.

Jak zmiana trafia do oryginalnej tablicy

Obliczana właściwość visibleItems na nowo wyznacza przynależność elementów na podstawie oryginalnej tablicy i bieżących ustawień filtra. ForEach otrzymuje te wyniki i tworzy po jednym wierszu dla każdego pasującego elementu. Filtrowanie przed utworzeniem wierszy pozwala też uniknąć umieszczania wewnątrz każdej iteracji warunku tworzącego zero lub jeden wiersz — ten wzorzec omawiają wskazówki Apple dotyczące wydajności ForEach.

Gdy wiersz odczytuje swoje powiązanie, funkcja pobierająca wyszukuje jego identyfikator w bieżącej oryginalnej tablicy. Zrzut jest jedynie wartością zapasową dla elementu, który został już usunięty. Dopóki element istnieje, funkcja pobierająca zwraca jego bieżącą wartość z oryginalnej tablicy, a nie zrzut przechwycony podczas tworzenia wiersza.

Gdy użytkownik edytuje pole Label name, zapis $item.name tworzy projekcję powiązania elementu na jego zapisywalną właściwość nazwy. Apple opisuje tę projekcję właściwości w dokumentacji indeksu dynamicMember subscript typu Binding. Funkcja ustawiająca wyszukuje następnie bieżący indeks oryginalnego elementu i aktualizuje jego edytowalne pola, zachowując zapisany identyfikator.

Wyszukiwanie korzysta z firstIndex(where:), które zwraca opcjonalny indeks, a gdy nie ma dopasowania — nil. Kod sprawdza ten wynik przed indeksowaniem tablicy. Ponieważ wyszukiwanie jest powtarzane przy każdym zapisie, odwrócenie oryginalnej tablicy nie pozostawia funkcji ustawiającej nieaktualnego indeksu. Zobacz dokumentację Apple dla metody firstIndex(where:).

Co się dzieje, gdy wiersz znika?

Możliwe są dwa różne przypadki. Jeśli zmiana sprawia, że element przestaje spełniać kryteria filtra, nadal istnieje on w oryginalnej tablicy. Zmiana zostaje zachowana, ale wiersz znika z wyników. Na przykład wyłączenie opcji Included przy aktywnym filtrze Included only ukrywa wiersz; ponowne wyłączenie filtra go przywraca.

Jeśli element został usunięty z oryginalnej tablicy, późniejszy zapis przez stare powiązanie zostaje odrzucony. Przykład wyświetla komunikat Edit skipped: label was removed. Nie wstawia zrzutu z powrotem do tablicy. Zwracanie zrzutu przez funkcję pobierającą i odrzucanie zapisów dotyczących brakującego elementu to jawnie przyjęte zasady tego przykładu, a nie gwarancje zapewniane przez SwiftUI.

Filtrowanie nazw na bieżąco wymaga też decyzji dotyczącej interakcji. Apple dokumentuje, że pole TextField powiązane z ciągiem znaków aktualizuje jego wartość nieprzerwanie podczas edycji. W efekcie wiersz może zniknąć natychmiast, gdy jego nowa nazwa przestanie pasować do zapytania. Aby zmiana nazwy przebiegała bez przerw, najpierw wyczyść zapytanie albo zaprojektuj osobny edytor, który zatwierdza wersję roboczą po jawnej akcji. Zobacz TextField.

Sprawdź działanie w krótkiej próbie na komputerze

Poniższe kroki opisują oczekiwane działanie wywnioskowane z przykładu; nie są raportem z jego uruchomienia. Rozpocznij od nowego uruchomienia z początkowymi etykietami, a następnie wykonaj kroki po kolei.

Działanie Oczekiwany wynik
Wpisz Ring w polu Filter labels. Pojawia się tylko Ring.
Zmień jego nazwę na Ring Two. Odczyt oryginalnej tablicy zmienia się na Grid · Tile · Ring Two.
Wyczyść zapytanie i kliknij Reverse original order. Odczyt zmienia się na Ring Two · Tile · Grid.
Zmień nazwę Grid na Grid Two. Zmienia się wartość tylko tego elementu w oryginalnej tablicy.
Włącz Included only, a następnie wyłącz Included dla Grid Two. Ten wiersz znika, ale jego nazwa pozostaje widoczna w odczycie oryginalnej tablicy.
Wyłącz Included only. Grid Two wraca z wyłączonym przełącznikiem.
Wpisz Tile, a następnie kliknij jego przycisk Remove. Wiersz i wpis w oryginalnej tablicy znikają.

Jeśli zmieni się niewłaściwy element, sprawdź, czy widoczne przesunięcie jest używane jako indeks oryginalnej tablicy. Jeśli zmiany znikają po wyczyszczeniu filtra, sprawdź, czy wiersz nie ma własnej kopii @State. Jeśli zmieniony element zachowuje się jak nowy, sprawdź, czy jego identyfikator nie zależy od edytowanej zawartości. Te kontrole dotyczą trzech jawnie określonych w przykładzie relacji: własności danych źródłowych, miejsca zapisu i tożsamości wiersza.

Uwzględnij ograniczenia macOS i API

W przypadku krótkich edytowalnych etykiet pole SwiftUI TextField z przykładu zapewnia wymagany interfejs. Rozbudowany edytor dokumentów może zamiast tego wymagać AppKitowego NSTextView, który obsługuje tekst formatowany i integruje się z komponentami systemu tekstowego, takimi jak NSTextStorage i NSTextLayoutManager. Zmienia to kontrolkę edycji; aplikacja nadal musi zdecydować, który element oryginalnego modelu otrzymuje każdą zmianę. Zobacz NSTextView.

Obsługiwanym sposobem umieszczenia widoku AppKit w hierarchii SwiftUI jest NSViewRepresentable. Apple opisuje tworzenie i aktualizowanie reprezentowanego widoku oraz użycie koordynatora do przekazywania interakcji. W przyszłym niestandardowym edytorze zachowaj ten sam cel zapisu wskazywany stabilnym identyfikatorem. Pozwól SwiftUI zarządzać układem reprezentowanego widoku, zamiast bezpośrednio zmieniać zarządzane przez niego ramkę lub granice. Zobacz NSViewRepresentable.

Rozszerzenie AppKit ma też ograniczenia związane z TextKit: Apple dokumentuje, że dostęp do NSTextView.layoutManager może przełączyć widok w tryb zgodności korzystający z NSLayoutManager. Przed użyciem sprawdź dostępność i działanie konkretnych API tekstowych. Samo rozwiązanie z powiązaniem kolekcji nie wymaga dostosowywania TextKit. Zobacz omówienie zgodności w NSTextView.

W przypadku większych kolekcji uwzględnij koszt wyszukiwania. Apple dokumentuje liniową złożoność zarówno dla filter(_:), jak i firstIndex(where:); dlatego wielokrotne wyszukiwanie przy każdym wierszu może stawać się istotnym kosztem wraz ze wzrostem kolekcji. Zmierz działanie interfejsu przed wprowadzeniem pamięci podręcznej mapującej identyfikator na indeks i odbudowuj taką pamięć podręczną za każdym razem, gdy zmiany strukturalne modyfikują indeksy. Zobacz filter(_:) i firstIndex(where:).

Dostęp do interfejsu użytkownika za pośrednictwem tych obliczanych powiązań utrzymuj na głównym aktorze, tak jak w przykładzie. Dokumentacja inicjalizatora powiązania Apple wyjaśnia, że dozwolony dostęp przez obliczane powiązanie zależy od izolacji jego domknięć. Przekazanie powiązania w inne miejsce nie znosi tych ograniczeń. Zobacz init(get:set:).

W tym edytorze przefiltrowanych etykiet zachowaj jedną oryginalną tablicę, używaj stabilnych identyfikatorów wierszy i wyszukuj cel zapisu w chwili edycji. Następnie świadomie wybierz zasady dotyczące znikających elementów: element ukryty przez filtr zachowuje zmianę, a element usunięty odrzuca kolejne zapisy i wyświetla stosowne wyjaśnienie.

Źródła