La ripetuta inizializzazione di una riga SwiftUI non dimostra che la riga sia scomparsa o abbia perso il proprio stato. Per diagnosticare un’interfaccia Mac con scorrimento, monitora separatamente la creazione, lo stato mantenuto, le callback di apparizione e la visibilità nell’area di visualizzazione.

Questa guida è rivolta agli sviluppatori che indagano sui log ripetuti delle righe nella propria app macOS. L’obiettivo concreto è determinare se un aggiornamento si limita a creare un altro valore di vista, reimposta lo stato della riga oppure modifica quali righe occupano l’area visibile durante lo scorrimento. L’esempio usa etichette sintetiche e pulsanti comuni.

Che cosa indica davvero ciascun segnale del ciclo di vita?

Una riga SwiftUI usa in genere una struttura, che è un tipo valore di Swift. Costruire un altro valore di struttura è diverso dal creare un oggetto di riferimento persistente. La documentazione di Swift spiega questa distinzione in Value And Reference Types In Swift.

SwiftUI aggiunge un proprio modello di identità. Apple spiega che valori di vista temporanei possono rappresentare la stessa identità tra un aggiornamento e l’altro e che la durata di una vista segue tale identità. Di conseguenza, un log dell’inizializzatore non dimostra che la durata della riga precedente sia terminata. Vedi Demystify SwiftUI.

Usa questi segnali per rispondere a domande diverse:

Segnale Domanda a cui risponde Limite interpretativo
Inizializzatore della riga È stato costruito un altro valore di riga? Non dimostra l’apparizione o la sostituzione dello stato.
Valutazione di body È stata valutata questa descrizione della vista? Non conta i fotogrammi visualizzati.
Stato della riga mantenuto Questo valore diagnostico è sopravvissuto all’aggiornamento? Descrive la continuità di quello stato, non l’occupazione dell’area visibile.
onAppear e onDisappear SwiftUI ha segnalato una transizione di apparizione? La tempistica dipende dal tipo di vista.
onScrollVisibilityChange La riga ha superato una soglia di visibilità scelta? Misura la condizione di visibilità documentata per lo scorrimento.

Apple documenta che onAppear viene completato prima del primo fotogramma renderizzato; la tempistica esatta dipende dal tipo di vista. onDisappear viene eseguito dopo la scomparsa, anche in questo caso con tempistiche che variano in base alla vista. Nessuno dei due metodi documenta la deallocazione dello stato. Tieni distinti questi eventi delle callback dalle conclusioni sulla durata della memoria. Vedi onAppear(perform:) e onDisappear(perform:).

Per una domanda sull’area visibile, scegli esplicitamente una soglia. Su macOS 15 e versioni successive, onScrollVisibilityChange(threshold:_:) segnala se la vista supera la soglia di visibilità specificata; il valore predefinito è 0.5. Apple documenta anche una callback iniziale quando la vista appare avendo già raggiunto la soglia. Vedi onScrollVisibilityChange(threshold:_:).

In questo articolo, “visibile nell’area di visualizzazione” significa che viene soddisfatta tale soglia di scorrimento. Non misura se qualcuno ha guardato la riga.

Crea un piccolo esperimento che separi i segnali

Crea un’app SwiftUI per macOS usando un SDK che includa le API di macOS 15 e imposta il deployment target su macOS 15 o versione successiva. Sostituisci le dichiarazioni generate per l’app e la vista dei contenuti con il programma completo seguente. Mantieni un solo punto di ingresso @main.

L’esempio usa ScrollView e LazyVStack per rendere esplicito il comportamento dell’area visibile. Apple descrive LazyVStack come un componente che crea gli elementi quando necessario. Questo esperimento non presuppone che List abbia lo stesso comportamento di caricamento o mantenimento. Vedi LazyVStack.

import SwiftUI
import Foundation

@main
struct RowLifetimeLabApp: App {
    var body: some Scene {
        WindowGroup("Row Lifetime Lab") {
            ProbeView()
        }
    }
}

struct ProbeView: View {
    @State private var revision = 0
    @State private var generation = 0

    var body: some View {
        VStack(spacing: 12) {
            HStack {
                Button("Update rows") {
                    revision += 1
                }
                Button("Reset identity") {
                    generation += 1
                }
                Text("Revision \(revision)")
            }

            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(1...60, id: \.self) { rowID in
                        ProbeRow(rowID: rowID, revision: revision)
                    }
                }
                .padding()
            }
            .id(generation)
        }
        .padding()
        .frame(minWidth: 560, minHeight: 360)
    }
}

struct ProbeRow: View {
    let rowID: Int
    let revision: Int
    private let constructionID: UUID

    @State private var appearanceToken: UUID? = nil
    @State private var count = 0

    init(rowID: Int, revision: Int) {
        self.rowID = rowID
        self.revision = revision
        self.constructionID = UUID()
        print("init row=\(rowID) value=\(constructionID)")
    }

    var body: some View {
        let _ = print("body row=\(rowID) value=\(constructionID)")

        HStack {
            VStack(alignment: .leading) {
                Text("Sample row \(rowID)")
                Text("Revision \(revision)")
                Text(appearanceToken?.uuidString ?? "Awaiting appearance")
                    .font(.caption.monospaced())
            }
            Spacer()
            Button("Count \(count)") {
                count += 1
            }
        }
        .padding(12)
        .frame(height: 80)
        .background(Color.gray.opacity(0.12))
        .onAppear {
            if appearanceToken == nil {
                appearanceToken = UUID()
            }
            print("appear row=\(rowID) token=\(tokenDescription)")
        }
        .onDisappear {
            print("disappear row=\(rowID) token=\(tokenDescription)")
        }
        .onScrollVisibilityChange(threshold: 0.5) { visible in
            print("viewport row=\(rowID) visible=\(visible)")
        }
    }

    private var tokenDescription: String {
        appearanceToken?.uuidString ?? "unassigned"
    }
}

I due UUID hanno scopi diversi. constructionID identifica ogni invocazione dell’inizializzatore. appearanceToken inizia vuoto e viene assegnato alla prima apparizione osservata dello stato della riga mantenuto. Non registra l’istante in cui lo stato viene allocato. Il conteggio offre un altro valore visibile di cui puoi esaminare la continuità.

Apple documenta che SwiftUI gestisce l’archiviazione di State e aggiorna i contenuti dell’interfaccia che dipendono dal valore quando questo cambia. Perciò la diagnostica mantiene il token e il conteggio in uno stato privato, mentre le informazioni sulla creazione restano una normale proprietà memorizzata. Vedi State.

Il programma è stato esaminato rispetto alle API documentate, ma qui non è stato compilato né eseguito perché non era disponibile una toolchain Swift. Gli esempi di log seguenti descrivono come interpretare la tua esecuzione; non sono risultati acquisiti.

Esegui tre verifiche controllate e interpreta i risultati

1. Aggiorna gli input delle righe senza cambiarne l’identità

Esegui l’app in Xcode e lascia aperta la console di debug. Fai clic due volte sul pulsante Count di una riga visibile, annota il suo token, quindi fai clic su Update rows. La revisione passata alle righe cambia, mentre gli identificatori interi fissi restano invariati.

Se compaiono di nuovo messaggi dell’inizializzatore, ma la riga selezionata mantiene il token e il conteggio, hai elementi per concludere che si è verificata una nuova creazione del valore mentre lo stato diagnostico è rimasto intatto. Non classificare quei messaggi dell’inizializzatore come durate distinte sullo schermo. La revisione visualizzata conferma che la riga ha ricevuto il nuovo input.

Il token di apparizione viene assegnato tramite lo stato, cosa che può a sua volta causare un altro aggiornamento. È un comportamento diagnostico intenzionale. Non aspettarti un numero preciso di messaggi dell’inizializzatore o di body e rimuovi queste sonde al termine dell’indagine.

2. Fai uscire una riga dall’area visibile e poi riportala al suo interno

Scorri lentamente finché la riga selezionata non supera la soglia del 50%. Confronta i messaggi viewport con quelli di apparizione, quindi torna alla riga. Controlla di nuovo il token e il conteggio.

Se, al ritorno, il token è rimasto invariato, ciò mostra la continuità di questo stato diagnostico durante l’uscita dall’area visibile. Un token diverso indica che quello precedente non è stato mantenuto. Per spiegare una reimpostazione in un’app reale, verifica i cambiamenti di identità, la rimozione condizionale e il comportamento di mantenimento del contenitore, invece di dedurre la causa dal solo scorrimento.

Le attuali indicazioni di Apple spiegano che gli stack lazy possono precaricare il corpo e il layout di una vista prima della sua apparizione. Se lo scorrimento si inverte, una vista precaricata potrebbe non ricevere mai onAppear. Apple spiega anche che lo stato degli elementi fuori schermo di uno stack lazy può essere scartato in seguito e consiglia di mantenere i dati che devono sopravvivere allo scorrimento in un modello o in una vista esterna. Vedi Dive into lazy stacks and scrolling with SwiftUI.

Per questo il conteggio locale alla riga nell’esempio è utile come sonda. In un’interfaccia di modifica reale, memorizza le modifiche che devono sopravvivere alla rimozione della riga in un proprietario con durata maggiore, usando l’identificatore stabile dell’elemento come chiave.

3. Reimposta deliberatamente l’identità

Fai clic su Reset identity. Il programma modifica l’identità associata all’intera vista di scorrimento, offrendo un confronto deliberato con il normale aggiornamento della revisione. Apple documenta esplicitamente che modificare il valore fornito a id(_:) reimposta l’identità della vista, compreso il suo stato. Vedi id(_:).

Controlla che le righe appena apparse abbiano token nuovi e conteggi iniziali. Poiché la reimpostazione si applica alla gerarchia della vista di scorrimento, non usarla come test controllato della conservazione della posizione di scorrimento.

Usa questa guida decisionale per esaminare la tua traccia:

Osservazione Conclusione supportata Verifica successiva
Nuovi messaggi dell’inizializzatore; token e conteggio persistono È avvenuta una nuova creazione mentre lo stato diagnostico è persistito. Esamina il costo dell’inizializzatore se gli aggiornamenti sono lenti.
Le callback di apparizione si ripetono; il token persiste L’apparizione ripetuta non ha sostituito quel token. Usa le callback di visibilità nell’area per le funzioni che dipendono da una soglia.
Token e conteggio si reimpostano dopo un normale aggiornamento Lo stato della riga non è sopravvissuto come previsto. Esamina gli ID dei dati, i modificatori id che cambiano e i rami condizionali.
Una riga viene inizializzata ma non segnala mai un’apparizione La sola creazione non ha dimostrato l’apparizione. Considera la preparazione lazy e le successive variazioni dello scorrimento.
La visibilità nell’area diventa falsa La soglia di scorrimento scelta non è più soddisfatta. Decidi se quella soglia corrisponde al comportamento desiderato.

Per i dati dinamici, mantieni gli identificatori associati agli elementi sottostanti tra un aggiornamento e l’altro. Un identificatore generato di nuovo a ogni lettura impedisce la continuità. Esamina anche le identità degli antenati: gli ID stabili delle righe non possono preservare una riga all’interno di una gerarchia di viste la cui identità viene deliberatamente sostituita. Apple tratta gli identificatori stabili dei dati e l’identità strutturale in Demystify SwiftUI.

Qual è la soluzione supportata nelle versioni precedenti di macOS?

Il modificatore di visibilità durante lo scorrimento dell’esempio richiede macOS 15. Per abbassare il deployment target occorre un ramo di disponibilità con un’implementazione separata; onAppear non misura una soglia equivalente.

Se la tua app gestisce già un’interfaccia di scorrimento AppKit, NSScrollView.documentVisibleRect fornisce la porzione della vista del documento visibile attraverso la vista del contenuto, espressa nelle coordinate della vista del documento. Un’alternativa basata sulla geometria consiste nel confrontare quel rettangolo con i rettangoli delle righe nello stesso sistema di coordinate, ricalcolando dopo le modifiche allo scorrimento e al layout. Vedi documentVisibleRect.

Per esempio, una regola scelta esplicitamente potrebbe classificare una riga come visibile quando almeno metà della sua area rettangolare interseca il rettangolo visibile del documento. Questa è una regola applicativa proposta, non l’affermazione che riproduca l’implementazione di SwiftUI. Gestisci esplicitamente i rettangoli vuoti e la conversione delle coordinate.

SwiftUI fornisce NSViewRepresentable per integrare una vista AppKit creata e gestita dall’app. I metodi documentati per creazione, aggiornamento, coordinamento e smantellamento forniscono un ponte supportato. Rispetta il controllo di SwiftUI sul layout della vista rappresentata: Apple sconsiglia di modificarne direttamente il frame o i limiti. Vedi NSViewRepresentable.

Questa soluzione alternativa riguarda una vista del documento AppKit di cui sei responsabile. Non richiede supposizioni sulle viste di supporto private di una List SwiftUI.

Il layout di TextKit dimostra che il testo è sullo schermo?

TextKit richiede la stessa distinzione tra preparazione e geometria dell’area visibile. Ciò è importante se una tua riga contiene una vista di testo AppKit e la domanda diventa quali segmenti di testo intersecano l’area visibile.

NSTextViewportLayoutController di TextKit 2, disponibile da macOS 12, gestisce un’area di layout attiva che in genere include l’area visibile più una regione di scorrimento oltre il limite. Il suo viewportBounds include il disegno oltre i limiti e il suo viewportRange facoltativo descrive il layout attuale dell’area visibile. Non si dovrebbe definire nessuno dei due come un insieme esatto di caratteri visibili. Vedi NSTextViewportLayoutController.

Per un’implementazione TextKit 2, usa la geometria del layout del testo, non gli eventi di allocazione. NSTextLayoutManager può enumerare segmenti di testo per un intervallo specificato. Confronta i rettangoli dei segmenti risultanti con il rettangolo di ritaglio effettivo dopo averli portati nello stesso sistema di coordinate. Tieni conto di textContainerOrigin della vista di testo; non presumere che le coordinate del contenitore di testo coincidano con quelle della vista del documento. Vedi enumerateTextSegments(in:type:options:using:) e textContainerOrigin.

Per un’implementazione TextKit 1 esistente, NSLayoutManager.glyphRange(forBoundingRect:in:) accetta un rettangolo nelle coordinate del contenitore di testo. Apple afferma che l’intervallo contiguo restituito può includere glifi esterni a quel rettangolo, quindi è un intervallo candidato da esaminare ulteriormente, non un elenco esatto di elementi visibili. Vedi glyphRange(forBoundingRect:in:).

Mantieni il motore di testo durante la diagnosi. Apple documenta che accedere alla proprietà layoutManager di un NSTextView o di un contenitore di testo su macOS 12 e versioni successive comporta il passaggio alla modalità di compatibilità che usa NSLayoutManager. Per un percorso TextKit 2, usa textLayoutManager e le relative API. Vedi NSTextView.

Scegli il segnale che risponde alla domanda sull’interfaccia: i log dell’inizializzatore per la creazione, lo stato mantenuto per la continuità, le callback di apparizione per le transizioni di apparizione e la geometria dell’area visibile per la visibilità. Un esperimento con ID stabili e una sola reimpostazione deliberata dell’identità rende osservabili queste differenze senza dover indovinare dal volume dell’output della console.

Fonti