La réinitialisation répétée des lignes SwiftUI ne prouve pas qu’une ligne a disparu ou perdu son état. Pour diagnostiquer une interface Mac avec défilement, suivez séparément la construction, l’état conservé, les rappels d’apparition et la visibilité dans la zone d’affichage.
Ce guide s’adresse aux développeurs qui examinent les journaux répétés de lignes dans leur propre app macOS. L’objectif concret est de déterminer si une mise à jour crée simplement une autre valeur de vue, réinitialise l’état de la ligne ou modifie les lignes qui occupent la zone d’affichage du défilement. L’exemple utilise des libellés fictifs et des boutons classiques.
Que nous apprend réellement chaque signal du cycle de vie ?
Une ligne SwiftUI utilise généralement une structure, qui est un type valeur Swift. Construire une autre valeur de structure diffère de la création d’un objet de référence persistant. La documentation de Swift explique cette distinction dans Types valeur et types référence en Swift.
SwiftUI ajoute son propre modèle d’identité. Apple explique que des valeurs de vue temporaires peuvent représenter la même identité d’une mise à jour à l’autre, et que la durée de vie d’une vue suit cette identité. Par conséquent, un journal d’initialisation ne permet pas d’établir que la durée de vie de la ligne précédente a pris fin. Consultez Démystifier SwiftUI.
Utilisez ces signaux pour répondre à différentes questions :
| Signal | Question à laquelle il répond | Limite d’interprétation |
|---|---|---|
| Initialiseur de ligne | Une autre valeur de ligne a-t-elle été construite ? | Ne permet pas d’établir une apparition ou un remplacement de l’état. |
Évaluation de body |
La description de cette vue a-t-elle été évaluée ? | Ne compte pas les images affichées. |
| État conservé de la ligne | Cette valeur de diagnostic a-t-elle survécu à la mise à jour ? | Décrit la continuité de cet état, pas l’occupation de la zone d’affichage. |
onAppear et onDisappear |
SwiftUI a-t-il signalé une transition d’apparition ? | Le moment dépend du type de vue. |
onScrollVisibilityChange |
La ligne a-t-elle franchi un seuil choisi dans la zone d’affichage ? | Mesure la condition de visibilité du défilement documentée. |
Apple indique que onAppear s’achève avant la première image rendue, le moment exact dépendant du type de vue. onDisappear s’exécute après la disparition, là aussi selon un moment qui dépend de la vue. Aucune de ces méthodes ne documente la désallocation de l’état. Distinguez ces événements de rappel des conclusions sur la durée de vie du stockage. Consultez onAppear(perform:) et onDisappear(perform:).
Pour une question concernant la zone d’affichage, choisissez explicitement un seuil. À partir de macOS 15, onScrollVisibilityChange(threshold:_:) indique si la vue franchit le seuil de visibilité fourni ; la valeur par défaut est 0,5. Apple documente également un rappel initial lorsque la vue apparaît alors que le seuil est déjà atteint. Consultez onScrollVisibilityChange(threshold:_:).
Dans cet article, « visible dans la zone d’affichage » signifie que la vue satisfait ce seuil de défilement. Cela ne mesure pas si quelqu’un a regardé la ligne.
Créer une petite expérience qui distingue les signaux
Créez une app SwiftUI pour macOS avec un SDK qui inclut les API de macOS 15, et définissez macOS 15 ou une version ultérieure comme cible de déploiement. Remplacez les déclarations générées de l’app et de la vue de contenu par le programme complet ci-dessous. Gardez un seul point d’entrée @main.
L’exemple utilise ScrollView et LazyVStack pour rendre explicite le comportement de la zone d’affichage. Apple décrit LazyVStack comme créant les éléments selon les besoins. Cette expérience ne suppose pas que List ait un comportement identique de chargement ou de conservation. Consultez 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"
}
}
Les deux UUID ont des rôles différents. constructionID identifie chaque appel à l’initialiseur. appearanceToken est initialement vide et reçoit une valeur lors de la première apparition observée pour l’état conservé de la ligne. Il ne date pas l’allocation de l’état. Le compteur fournit une autre valeur visible dont vous pouvez examiner la continuité.
Apple indique que SwiftUI gère le stockage de State et met à jour le contenu de l’interface qui en dépend lorsque sa valeur change. Le diagnostic conserve donc son jeton et son compteur dans un état privé, tandis que les informations de construction restent une propriété stockée ordinaire. Consultez State.
Le programme a été vérifié par rapport aux API documentées, mais n’a pas été compilé ni exécuté ici, car aucun environnement Swift n’était disponible. Les exemples de journaux ci-dessous expliquent comment interpréter votre exécution ; il ne s’agit pas de résultats enregistrés.
Effectuer trois vérifications contrôlées et interpréter les résultats
1. Mettre à jour les entrées des lignes sans changer leur identité
Lancez l’app dans Xcode et laissez sa console de débogage ouverte. Cliquez deux fois sur le bouton Count d’une ligne visible, notez son jeton, puis cliquez sur Update rows. La révision transmise aux lignes change, tandis que les identifiants entiers fixes restent les mêmes.
Si de nouveaux messages d’initialisation apparaissent alors que la ligne sélectionnée conserve son jeton et son compteur, vous avez la preuve d’une construction répétée de valeurs avec un état de diagnostic conservé. Ne classez pas ces messages d’initialisation comme des durées de vie distinctes à l’écran. La révision affichée confirme que la ligne a reçu la nouvelle entrée.
Le jeton d’apparition est attribué via l’état, ce qui peut déclencher une autre mise à jour. C’est intentionnel pour ce diagnostic. Ne vous attendez pas à un nombre précis de messages d’initialisation ou de body, et supprimez ces sondes une fois l’enquête terminée.
2. Faire défiler une ligne hors de la zone d’affichage, puis la ramener
Faites défiler lentement jusqu’à ce que la ligne sélectionnée franchisse le seuil de 50 %. Comparez les messages viewport à ceux d’apparition, puis revenez à la ligne. Examinez de nouveau son jeton et son compteur.
Un jeton conservé au retour montre que cet état de diagnostic a persisté pendant cette excursion. Un jeton différent signifie que le jeton précédent n’a pas été conservé. Pour expliquer une réinitialisation dans une vraie app, examinez les changements d’identité, la suppression conditionnelle et le comportement de conservation du conteneur, plutôt que de déduire la cause du seul défilement.
Les recommandations actuelles d’Apple indiquent que les piles différées peuvent préparer le corps et la disposition d’une vue avant son apparition. Si le défilement repart en sens inverse, une vue préchargée peut ne jamais recevoir onAppear. Apple explique aussi que l’état d’une pile différée hors écran peut finir par être supprimé et recommande de conserver les données qui doivent survivre au défilement dans un modèle ou une vue parente. Consultez Découvrir les piles différées et le défilement avec SwiftUI.
Le compteur local à la ligne de l’exemple constitue ainsi une sonde utile. Dans une véritable interface d’édition, stockez les modifications qui doivent survivre à la suppression d’une ligne dans un propriétaire de durée de vie plus longue, indexé par l’identifiant stable de l’élément.
3. Réinitialiser volontairement l’identité
Cliquez sur Reset identity. Le programme modifie l’identité associée à l’ensemble de la vue de défilement, ce qui permet de la comparer directement à la mise à jour ordinaire de révision. Apple indique explicitement que modifier la valeur fournie à id(_:) réinitialise l’identité de la vue, y compris son état. Consultez id(_:).
Examinez les nouvelles lignes qui apparaissent : leurs jetons et compteurs doivent repartir de zéro. Comme la réinitialisation s’applique à toute la sous-arborescence de la vue de défilement, ne l’utilisez pas pour tester de manière contrôlée la conservation de la position de défilement.
Utilisez ce guide de décision pour examiner votre propre trace :
| Observation | Conclusion étayée | Vérification suivante |
|---|---|---|
| Nouveaux messages d’initialisation ; le jeton et le compteur persistent | La construction a eu lieu pendant que l’état de diagnostic persistait. | Examinez le coût de l’initialiseur si les mises à jour sont lentes. |
| Les rappels d’apparition se répètent ; le jeton persiste | Les apparitions répétées n’ont pas remplacé ce jeton. | Utilisez les rappels de visibilité pour les comportements dépendant d’un seuil. |
| Le jeton et le compteur sont réinitialisés après une mise à jour ordinaire | L’état de la ligne n’a pas survécu comme prévu. | Examinez les identifiants des données, les modificateurs id qui changent et les branches conditionnelles. |
| Une ligne est initialisée mais ne signale jamais d’apparition | La construction seule n’a pas établi une apparition. | Tenez compte de la préparation différée et des changements ultérieurs du défilement. |
| La visibilité dans la zone d’affichage devient fausse | Le seuil de défilement choisi n’est plus satisfait. | Déterminez si ce seuil correspond au comportement voulu. |
Pour les données dynamiques, conservez les identifiants associés aux éléments sous-jacents lors des mises à jour. Un identifiant nouvellement généré à chaque lecture empêche la continuité. Examinez aussi les identités des vues parentes : des identifiants de ligne stables ne peuvent pas préserver une ligne au sein d’une sous-arborescence dont vous remplacez volontairement l’identité. Apple aborde les identifiants stables des données et l’identité structurelle dans Démystifier SwiftUI.
Quelle approche est prise en charge sur les anciennes versions de macOS ?
Le modificateur de visibilité du défilement de l’exemple nécessite macOS 15. Pour abaisser la cible de déploiement, il faut une branche de disponibilité dotée d’une implémentation distincte ; onAppear ne mesure pas un seuil de manière équivalente.
Si votre app possède déjà une interface de défilement AppKit, NSScrollView.documentVisibleRect fournit la portion de sa vue de document visible à travers la vue de contenu, exprimée dans les coordonnées de la vue de document. Une autre possibilité consiste à comparer cette zone aux rectangles de vos propres lignes dans le même système de coordonnées, en recalculant après le défilement et les changements de disposition. Consultez documentVisibleRect.
Par exemple, une règle choisie explicitement pourrait considérer une ligne comme visible lorsqu’au moins la moitié de sa surface rectangulaire intersecte le rectangle visible du document. Il s’agit d’une règle d’application proposée, pas d’une affirmation selon laquelle elle reproduit l’implémentation de SwiftUI. Traitez explicitement les rectangles vides et la conversion des coordonnées.
SwiftUI fournit NSViewRepresentable pour intégrer une vue AppKit créée et gérée par votre app. Ses méthodes documentées de création, de mise à jour, de coordination et de démontage constituent un pont pris en charge. Respectez le contrôle exercé par SwiftUI sur la disposition de la vue représentée ; Apple déconseille de modifier directement son cadre ou ses limites. Consultez NSViewRepresentable.
Cette solution de repli concerne une vue de document AppKit dont vous êtes propriétaire. Elle ne nécessite aucune hypothèse sur les vues internes privées d’une List SwiftUI.
La disposition de TextKit prouve-t-elle que le texte est à l’écran ?
TextKit exige la même distinction entre préparation et géométrie de la zone d’affichage. Cela importe si votre ligne contient sa propre vue de texte AppKit et que vous cherchez quels segments de texte intersectent la zone visible.
NSTextViewportLayoutController de TextKit 2, disponible à partir de macOS 12, gère une zone de disposition active qui inclut généralement la zone visible ainsi qu’une région de dépassement de défilement. Son viewportBounds inclut le débordement, et son viewportRange facultatif décrit la disposition actuelle de la zone d’affichage. Aucun des deux ne doit être présenté comme un ensemble exact de caractères visibles. Consultez NSTextViewportLayoutController.
Pour une implémentation TextKit 2, utilisez la géométrie de disposition du texte plutôt que les événements d’allocation. NSTextLayoutManager peut énumérer les segments de texte pour une plage de texte donnée. Comparez les rectangles des segments obtenus avec le véritable rectangle de découpage, après les avoir placés dans le même système de coordonnées. Tenez compte de textContainerOrigin de la vue de texte ; ne supposez pas que les coordonnées du conteneur de texte correspondent à celles de la vue de document. Consultez enumerateTextSegments(in:type:options:using:) et textContainerOrigin.
Pour une implémentation TextKit 1 existante, NSLayoutManager.glyphRange(forBoundingRect:in:) accepte un rectangle en coordonnées du conteneur de texte. Selon Apple, la plage contiguë renvoyée peut inclure des glyphes situés en dehors de ce rectangle ; elle constitue donc une plage candidate à examiner davantage, plutôt qu’une liste exacte de caractères visibles. Consultez glyphRange(forBoundingRect:in:).
Préservez le moteur de texte pendant le diagnostic. Apple indique que l’accès à la propriété layoutManager d’un NSTextView ou d’un conteneur de texte sous macOS 12 ou une version ultérieure déclenche le passage au mode de compatibilité utilisant NSLayoutManager. Pour suivre la voie TextKit 2, utilisez textLayoutManager et ses API. Consultez NSTextView.
Choisissez le signal qui répond à la question concernant l’interface : les journaux d’initialisation pour la construction, l’état conservé pour la continuité, les rappels d’apparition pour les transitions d’apparition et la géométrie de la zone d’affichage pour la visibilité. Une expérience avec des identifiants stables et une seule réinitialisation d’identité volontaire permet d’observer ces différences sans les deviner à partir du volume de la sortie console.
Sources
- Types valeur et types référence en Swift — Swift.org
- Démystifier SwiftUI — Apple
onAppear(perform:)— AppleonDisappear(perform:)— AppleonScrollVisibilityChange(threshold:_:)— AppleLazyVStack— AppleState— Apple- Découvrir les piles différées et le défilement avec SwiftUI — Apple
id(_:)— AppledocumentVisibleRect— AppleNSViewRepresentable— AppleNSTextViewportLayoutController— AppleenumerateTextSegments(in:type:options:using:)— AppletextContainerOrigin— AppleglyphRange(forBoundingRect:in:)— AppleNSTextView— Apple