Per animare gli elementi che entrano ed escono da una raccolta SwiftUI filtrata, mantieni stabile l’identità di ciascun elemento, associa una transizione a ogni riga e anima le modifiche agli ID visibili. Questo tutorial crea una piccola finestra Mac con etichette sintetiche locali e un filtro funzionante.

L’attività per il lettore è specifica: digitare in un campo filtro o modificare un interruttore di categoria, quindi vedere le righe corrispondenti entrare, quelle non corrispondenti uscire e le righe rimanenti assestarsi nelle nuove posizioni. Apple documenta le transizioni di inserimento e rimozione come parte di una modifica di stato animata. L’implementazione seguente applica questo modello a una raccolta. Animazioni

Cosa fa funzionare correttamente l’animazione del filtro?

Tre responsabilità distinte lavorano insieme. Il filtro determina quali record appartengono alla raccolta visualizzata. L’identità indica a SwiftUI quali record persistono tra gli aggiornamenti. La transizione descrive come appare o scompare una riga, mentre l’animazione ne definisce la temporizzazione. Aggiungere soltanto una transizione non crea una modifica di stato animata. ForEach, Animazioni

Assegna a ogni record un identificatore memorizzato che rimanga invariato durante il filtraggio. Il protocollo Swift Identifiable rappresenta l’identità stabile di un’entità; ForEach accetta elementi identificabili o una key path di identità esplicita. In questo esempio, quattro record sintetici sono identificati da numeri interi fissi. Identifiable, ForEach

Evita di identificare le righe filtrate in base alla loro posizione corrente nell’array. Rimuovere un record precedente cambia le posizioni successive, anche se quei record sono sempre gli stessi elementi. Allo stesso modo, un identificatore calcolato che genera un nuovo UUID a ogni accesso distrugge la continuità. Apple illustra entrambi i problemi nella spiegazione dell’identità in SwiftUI. Demystify SwiftUI

Per questo piccolo esempio, usa una VStack normale all’interno di una ScrollView. Apple consiglia uno stack normale per un numero ridotto di viste secondarie: le visualizza tutte insieme, mentre uno stack lazy crea le viste quando servono. In questo modo è più facile esaminare un esempio con quattro righe prima di introdurre il rendering differito. VStack, ScrollView

Crea l’esempio completo per Mac

Crea in Xcode un progetto di app macOS con SwiftUI e Swift e imposta come target di distribuzione macOS 13 o successivo per questo tutorial. Sostituisci il file di ingresso dell’app generato con il programma completo qui sotto. Mantieni esattamente una dichiarazione @main nel target: la documentazione Apple di App specifica un unico punto di ingresso. App

Il programma usa soltanto la propria finestra e quattro etichette locali. Include il modello, i dati di esempio, i controlli del filtro, la presentazione delle righe, il trigger dell’animazione e lo stato vuoto. Il codice è stato esaminato alla luce della documentazione recuperata, ma qui non è stato compilato né eseguito perché l’ambiente disponibile non dispone di una toolchain Swift.

import SwiftUI

@main
struct FilteredCollectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            FilteredCollectionView()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let isTile: Bool
}

private struct FilteredCollectionView: View {
    @Environment(\.accessibilityReduceMotion)
    private var reduceMotion

    @State private var query = ""
    @State private var tilesOnly = false

    private let items: [DemoItem] = [
        DemoItem(id: 1, title: "Tile A", isTile: true),
        DemoItem(id: 2, title: "Dot A", isTile: false),
        DemoItem(id: 3, title: "Tile B", isTile: true),
        DemoItem(id: 4, title: "Dot B", isTile: false)
    ]

    private var visibleItems: [DemoItem] {
        let needle = query.lowercased()
        return items.filter { item in
            let matchesText = needle.isEmpty
                || item.title.lowercased().contains(needle)
            let matchesCategory = !tilesOnly || item.isTile
            return matchesText && matchesCategory
        }
    }

    private var rowTransition: AnyTransition {
        .asymmetric(
            insertion: AnyTransition.offset(x: 0, y: 8)
                .combined(with: .opacity),
            removal: .opacity
        )
    }

    var body: some View {
        let displayedItems = visibleItems
        let displayedIDs = displayedItems.map(\.id)

        VStack(alignment: .leading, spacing: 12) {
            Text("Label Collection")
                .font(.title2)

            TextField("Filter labels", text: $query)
                .textFieldStyle(.roundedBorder)

            Toggle("Tiles only", isOn: $tilesOnly)

            Text("\(displayedItems.count) of \(items.count) labels")
                .font(.caption)

            ScrollView {
                VStack(alignment: .leading, spacing: 8) {
                    ForEach(displayedItems) { item in
                        HStack {
                            Text(item.title)
                            Spacer()
                            Text(item.isTile ? "Tile" : "Dot")
                                .foregroundStyle(.secondary)
                        }
                        .padding(12)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Color.blue.opacity(0.10))
                        )
                        .transition(rowTransition)
                    }

                    if displayedItems.isEmpty {
                        Text("No matching labels")
                            .foregroundStyle(.secondary)
                            .padding(12)
                            .transition(.opacity)
                    }
                }
                .padding(12)
                .animation(
                    reduceMotion
                        ? nil
                        : .easeInOut(duration: 0.22),
                    value: displayedIDs
                )
            }
            .frame(height: 300)
        }
        .padding(20)
        .frame(minWidth: 360)
    }
}

Il punto di ingresso dell’app presenta la raccolta tramite WindowGroup, il contenitore di scene di Apple per finestre con struttura identica. Se si apre un’altra finestra da quel gruppo, SwiftUI assegna alla sua gerarchia di viste uno stato indipendente, incluse le impostazioni del filtro di questo esempio. WindowGroup

Il filtro esegue un semplice confronto per sottostringa senza distinzione tra maiuscole e minuscole, convertendo entrambe le stringhe in minuscolo. È una scelta intenzionale per queste etichette sintetiche in inglese. Le due condizioni vengono combinate: un record deve corrispondere al testo e, quando è attivo, all’interruttore di categoria. L’array originale resta intatto, quindi cancellando i controlli vengono ripristinati tutti e quattro i record.

Perché osservare gli ID visibili invece del numero di risultati?

Il modificatore di animazione osserva displayedIDs, un array ordinato di identificatori stabili. Il animation(_:value:) di Apple applica l’animazione fornita quando cambia il valore equatable monitorato. Qui quel valore rappresenta gli elementi presenti e il loro ordine nella raccolta visualizzata. animation(_:value:)

Osservare soltanto il conteggio farebbe perdere un caso utile: modificare la ricerca da a a b sostituisce due righe con due righe diverse. Il conteggio resta due, ma gli ID cambiano da [1, 2] a [3, 4]. Osservare gli ID rileva anche eventuali riordinamenti se in seguito viene aggiunto l’ordinamento. Al contrario, una modifica della ricerca che lascia visibili gli stessi ID nello stesso ordine non attiva questo modificatore.

Il modificatore è applicato allo stack interno persistente che contiene sia le righe sia il messaggio dello stato vuoto. Il campo di testo, l’interruttore e il conteggio dei risultati sono al di fuori di questo ambito. Questa disposizione mantiene separati i controlli del filtro dall’animazione della raccolta e fornisce alle modifiche di inserimento e rimozione un antenato animato comune.

A ogni riga completa viene assegnata la propria transizione dopo la spaziatura interna e lo stile dello sfondo. L’inserimento combina un breve spostamento verticale con l’opacità; la rimozione usa solo l’opacità. Apple documenta combined(with:) per applicare entrambe le transizioni e asymmetric(insertion:removal:) per scegliere transizioni diverse nelle due direzioni. AnyTransition, asymmetric(insertion:removal:)

La durata esemplificativa è di 0,22 secondi. È una scelta di regolazione, non un valore ottimale documentato. Specificare easeInOut(duration:) rende esplicita la temporizzazione prevista; Apple definisce in secondi il parametro di durata. easeInOut(duration:)

Per un filtro attivato da un pulsante, un altro approccio supportato consiste nel modificare lo stato del filtro all’interno di withAnimation. Sceglilo quando vuoi che sia l’azione stessa a definire l’aggiornamento animato. Qui è pratico il modificatore basato sul valore, perché entrambi i controlli alimentano la stessa raccolta derivata. Apple documenta entrambi gli approcci. Animazioni

Verifica il comportamento con una sequenza di filtri prevedibile

Usa la sequenza seguente nell’app in esecuzione. Gli array di ID previsti sono ricavati dai dati di esempio e dal predicato del filtro; non sono risultati di esecuzione riportati.

Azione ID visibili previsti Cosa verificare
Iniziare con il campo vuoto e l’interruttore disattivato [1, 2, 3, 4] Sono presenti tutte e quattro le etichette.
Inserire a [1, 2] Le due etichette che terminano con B scompaiono.
Sostituire a con b [3, 4] Gli elementi presenti cambiano anche se il conteggio resta due.
Cancellare il campo, quindi attivare Solo riquadri [1, 3] Le righe con punti scompaiono; quelle con riquadri restano.
Inserire z [] Le righe rimanenti scompaiono e appare Nessuna etichetta corrispondente.
Cancellare il campo e disattivare l’interruttore [1, 2, 3, 4] Tutti i record tornano nell’ordine originale.

L’esempio legge accessibilityReduceMotion e, se tale preferenza è attiva, fornisce nil al posto di un’animazione. Apple afferma che le interfacce dovrebbero evitare animazioni estese quando questo valore è true; disattivare l’animazione di questa raccolta è una scelta implementativa dell’esempio. Il modificatore di animazione documenta che un’animazione nil non anima la vista. accessibilityReduceMotion, animation(_:value:)

Individua transizioni mancanti o impreviste

Controlla questi livelli nell’ordine indicato, così ogni osservazione restringe le possibili cause:

  1. Esamina gli ID filtrati. Verifica che il predicato produca i record previsti prima di modificare le impostazioni dell’animazione.
  2. Esamina la stabilità e l’unicità dell’identità. Un record mantenuto deve conservare il proprio identificatore e i record diversi devono avere identificatori diversi. Rimuovi gli ID casuali calcolati e gli ID basati sulla posizione da una raccolta che cambia.
  3. Esamina il trigger dell’animazione. Verifica che il valore monitorato cambi per l’aggiornamento che stai testando. Conteggi uguali non implicano elementi uguali.
  4. Esamina la posizione della transizione. Associa la transizione alla riga completa creata da ForEach, invece che soltanto al testo al suo interno.
  5. Esamina l’ambito dell’animazione. Mantieni presente lo stack genitore durante le modifiche al filtro. Evita che la sua identità dipenda dalla ricerca.
  6. Esamina le impostazioni che prevalgono. Controlla l’impostazione Reduce Motion e qualsiasi configurazione di transazione o animazione circostante prima di concludere che la transizione non funziona.

I controlli dell’identità seguono i requisiti documentati da Apple per identificatori stabili e univoci. I controlli rimanenti applicano a questa struttura di viste la distinzione operata da Apple tra aggiornamenti di stato animati e transizioni di inserimento o rimozione. Demystify SwiftUI, Animazioni

Gli ID stabili dei record non conservano indefinitamente lo stato di una vista rimossa. Apple spiega che la rimozione di una vista ne termina il ciclo di vita. Se una riga modificabile deve mantenere una bozza dopo essere stata esclusa dal filtro, memorizza la bozza fuori dalla riga e associala all’ID del record. Questo esempio evita il problema mostrando etichette immutabili. Demystify SwiftUI

Quando usare stack lazy, AppKit o TextKit?

Per raccolte più grandi, valuta LazyVStack, che crea i figli solo quando servono per la visualizzazione. Continua a filtrare i dati prima di passarli a ForEach: così ottieni una riga per ogni elemento fornito. La documentazione Apple di ForEach spiega che i contenitori lazy sono vantaggiosi quando ogni elemento genera un numero costante di viste. Dopo aver cambiato contenitore, verifica di nuovo lo scorrimento e le transizioni. LazyVStack, ForEach

Un’applicazione AppKit esistente può incorporare questa interfaccia SwiftUI usando NSHostingView, documentata come una vista AppKit che ospita una gerarchia SwiftUI. L’identità, il filtraggio e le transizioni della raccolta possono rimanere nella vista radice SwiftUI. L’hosting integra l’interfaccia nella gerarchia di viste dell’app. NSHostingView

TextKit serve a un’attività diversa: gestire l’archiviazione del testo e il layout personalizzato del testo, anche tramite NSTextView di AppKit. In questo esempio di filtraggio di etichette non serve un motore di layout del testo personalizzato. Introduci TextKit quando lo richiedono le esigenze di modifica o layout del testo della riga; mantieni l’appartenenza alla raccolta e le transizioni delle righe nell’interfaccia SwiftUI circostante. TextKit

Inizia con la sequenza di quattro record e verifica identità e appartenenza prima di regolare il movimento. Dopo aver verificato che funzionino come previsto sia la sostituzione a conteggio invariato sia il caso senza risultati, adatta il design delle righe e la sorgente dei dati mantenendo stabili gli identificatori e collegando il trigger dell’animazione alla raccolta visualizzata.

Fonti