Pour animer l’apparition et la disparition d’éléments dans une collection SwiftUI filtrée, conservez l’identité de chaque élément, associez une transition à chaque ligne et animez les modifications des identifiants visibles. Ce tutoriel crée une petite fenêtre Mac avec des libellés synthétiques locaux et un filtre fonctionnel.
La tâche proposée au lecteur est précise : saisir du texte dans un champ de filtre ou modifier un sélecteur de catégorie, puis voir les lignes correspondantes apparaître, les lignes non correspondantes disparaître et les lignes restantes se stabiliser à leur nouvelle position. Apple décrit les transitions d’insertion et de suppression comme faisant partie d’un changement d’état animé. L’implémentation ci-dessous applique ce modèle à une collection. Animations
Qu’est-ce qui permet d’animer correctement le filtrage ?
Trois responsabilités distinctes fonctionnent ensemble. Le filtre détermine quels enregistrements appartiennent à la collection affichée. L’identité indique à SwiftUI quels enregistrements se maintiennent d’une mise à jour à l’autre. La transition décrit l’apparition ou la disparition d’une ligne, tandis que l’animation en fournit le minutage. Ajouter une transition ne suffit pas à créer un changement d’état animé. ForEach, Animations
Attribuez à chaque enregistrement un identifiant stocké qui ne change pas lors du filtrage. Le protocole Swift Identifiable représente l’identité stable d’une entité ; ForEach accepte des éléments identifiables ou un chemin de clé d’identité explicite. Dans cet exemple, des entiers fixes identifient quatre enregistrements synthétiques. Identifiable, ForEach
Évitez d’identifier les lignes filtrées par leur position actuelle dans le tableau. La suppression d’un enregistrement précédent modifie les positions suivantes, même si ces enregistrements sont toujours les mêmes éléments. De même, un identifiant calculé qui génère un nouvel UUID à chaque accès rompt la continuité. Apple illustre ces deux problèmes dans son explication de l’identité SwiftUI. Demystify SwiftUI
Pour ce petit exemple, utilisez un VStack classique dans un ScrollView. Apple recommande une pile classique pour un petit nombre de sous-vues ; elle les affiche toutes ensemble, tandis qu’une pile différée crée les vues au besoin. Un exemple de quatre lignes est ainsi plus facile à examiner avant d’introduire le rendu différé. VStack, ScrollView
Créer l’exemple Mac complet
Dans Xcode, créez un projet d’application macOS avec SwiftUI et Swift, puis définissez la version minimale de macOS sur 13 ou ultérieure pour ce tutoriel. Remplacez le fichier d’entrée d’application généré par le programme complet ci-dessous. Conservez une seule déclaration @main dans la cible ; la documentation d’Apple sur App précise qu’il faut un point d’entrée unique. App
Le programme utilise uniquement sa propre fenêtre et quatre libellés locaux. Il comprend le modèle, les données d’exemple, les contrôles de filtrage, la présentation des lignes, le déclenchement de l’animation et l’état vide. Le code a été examiné à la lumière de la documentation consultée, mais n’a pas été compilé ni exécuté ici, car l’environnement disponible ne dispose pas d’une chaîne d’outils 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)
}
}
Le point d’entrée de l’application présente la collection au moyen de WindowGroup, le conteneur de scènes d’Apple pour des fenêtres de structure identique. Si une autre fenêtre est ouverte depuis ce groupe, SwiftUI lui attribue un état indépendant pour sa hiérarchie de vues, y compris les paramètres de filtre de cet exemple. WindowGroup
Le filtre effectue une simple comparaison de sous-chaîne insensible à la casse en mettant les deux chaînes en minuscules. Ce choix est délibéré pour ces libellés synthétiques en anglais. Les deux conditions sont combinées : un enregistrement doit correspondre au texte et, si le sélecteur est activé, à la catégorie. Le tableau d’origine reste intact ; vider les contrôles rétablit donc les quatre enregistrements.
Pourquoi observer les identifiants visibles au lieu du nombre de résultats ?
Le modificateur d’animation observe displayedIDs, un tableau ordonné d’identifiants stables. Le modificateur animation(_:value:) d’Apple applique l’animation fournie lorsque la valeur équatable surveillée change. Ici, cette valeur représente les éléments présents et leur ordre dans la collection affichée. animation(_:value:)
Se contenter d’observer le nombre manquerait un cas utile : remplacer la requête a par b remplace deux lignes par deux autres lignes. Le nombre reste égal à deux, mais les identifiants passent de [1, 2] à [3, 4]. L’observation des identifiants détecte aussi les changements d’ordre si un tri est ajouté ultérieurement. À l’inverse, un changement de requête qui laisse visibles les mêmes identifiants dans le même ordre ne déclenche pas ce modificateur.
Le modificateur se trouve sur la pile intérieure persistante qui contient les lignes et le message d’état vide. Le champ de texte, le sélecteur et le nombre de résultats sont à l’extérieur de cette portée. Cette disposition sépare les contrôles de filtre de l’animation de la collection et donne aux insertions et aux suppressions un ancêtre animé commun.
Chaque ligne complète reçoit sa transition après le remplissage et la mise en forme de l’arrière-plan. L’insertion combine un léger décalage vertical et une variation d’opacité ; la suppression utilise uniquement l’opacité. Apple indique que combined(with:) applique les deux transitions et que asymmetric(insertion:removal:) permet de choisir des transitions différentes selon le sens. AnyTransition, asymmetric(insertion:removal:)
La durée illustrative est de 0,22 seconde. Il s’agit d’un choix de réglage, pas d’une valeur optimale documentée. Préciser easeInOut(duration:) rend explicite le minutage souhaité ; Apple définit son paramètre de durée en secondes. easeInOut(duration:)
Pour un filtre commandé par un bouton, une autre méthode prise en charge consiste à modifier l’état du filtre dans withAnimation. Choisissez cette approche lorsque l’action elle-même doit définir la mise à jour animée. Le modificateur basé sur une valeur est pratique ici, car les deux contrôles alimentent la même collection dérivée. Apple documente les deux méthodes. Animations
Vérifier le comportement avec une séquence de filtrage prévisible
Suivez la séquence ci-dessous dans l’application en cours d’exécution. Les tableaux d’identifiants visibles attendus sont déduits des données d’exemple et du prédicat de filtre ; ils ne correspondent pas à des résultats d’exécution rapportés.
| Action | Identifiants visibles attendus | À vérifier |
|---|---|---|
| Commencer avec un champ vide et le sélecteur désactivé | [1, 2, 3, 4] |
Les quatre libellés sont présents. |
Saisir a |
[1, 2] |
Les deux libellés se terminant par B disparaissent. |
Remplacer a par b |
[3, 4] |
Les éléments présents changent, même si le nombre reste égal à deux. |
| Vider le champ, puis activer « Tuiles uniquement » | [1, 3] |
Les lignes de points disparaissent ; celles des tuiles restent. |
Saisir z |
[] |
Les lignes restantes disparaissent et « Aucun libellé correspondant » s’affiche. |
| Vider le champ et désactiver le sélecteur | [1, 2, 3, 4] |
Tous les enregistrements reviennent dans leur ordre d’origine. |
L’exemple lit accessibilityReduceMotion et fournit nil à la place d’une animation lorsque cette préférence est activée. Selon Apple, les interfaces doivent éviter les grandes animations lorsque cette valeur est vraie ; désactiver l’animation de cette collection relève du choix d’implémentation de l’exemple. Le modificateur d’animation précise qu’une animation nil n’anime pas la vue. accessibilityReduceMotion, animation(_:value:)
Diagnostiquer les transitions absentes ou inattendues
Vérifiez ces éléments dans l’ordre afin que chaque observation permette de mieux cerner la cause :
- Examiner les identifiants filtrés. Vérifiez que le prédicat produit les enregistrements attendus avant de modifier les paramètres d’animation.
- Examiner la stabilité et l’unicité des identifiants. Un enregistrement conservé doit garder le même identifiant et chaque enregistrement doit avoir un identifiant distinct. Supprimez les identifiants aléatoires calculés et ceux fondés sur la position dans une collection qui change.
- Examiner le déclencheur d’animation. Vérifiez que la valeur surveillée change lors de la mise à jour testée. Des nombres égaux n’impliquent pas des éléments identiques.
- Examiner l’emplacement de la transition. Associez la transition à la ligne complète créée par
ForEach, plutôt qu’à son seul texte intérieur. - Examiner la portée animée. Gardez la pile parente présente pendant les changements de filtre. Évitez de faire dépendre son identité de la requête.
- Examiner les remplacements de configuration. Vérifiez le réglage
Reduce Motionet toute configuration englobante de transaction ou d’animation avant de conclure que la transition ne fonctionne pas.
Les vérifications d’identité suivent les exigences documentées par Apple en matière d’identifiants stables et uniques. Les autres vérifications appliquent à la structure de vues de cet exemple la distinction d’Apple entre mises à jour d’état animées et transitions d’insertion ou de suppression. Demystify SwiftUI, Animations
Des identifiants d’enregistrement stables ne conservent pas indéfiniment l’état local d’une vue de ligne supprimée. Apple explique que la suppression d’une vue met fin à son cycle de vie. Si une ligne modifiable doit conserver un brouillon après avoir été exclue par le filtre, stockez ce brouillon à l’extérieur de la ligne, en l’associant à l’identifiant de l’enregistrement. Cet exemple évite ce problème en affichant des libellés immuables. Demystify SwiftUI
Quand utiliser des piles différées, AppKit ou TextKit ?
Pour les grandes collections, envisagez un LazyVStack, qui crée les éléments enfants uniquement lorsqu’ils doivent être affichés. Continuez à filtrer les données avant de les transmettre à ForEach : cela fournit une ligne par élément transmis. La documentation d’Apple sur ForEach explique que les conteneurs différés sont avantageux lorsque chaque élément produit un nombre constant de vues. Vérifiez à nouveau le défilement et les transitions après avoir changé de conteneur. LazyVStack, ForEach
Une application AppKit existante peut intégrer cette interface SwiftUI à l’aide de NSHostingView, documentée comme une vue AppKit hébergeant une hiérarchie SwiftUI. L’identité, le filtrage et les transitions de la collection peuvent rester dans sa vue racine SwiftUI. L’hébergement intègre l’interface à la hiérarchie de vues propre à l’application. NSHostingView
TextKit répond à un autre besoin : gérer le stockage du texte et la mise en page personnalisée, notamment au moyen de NSTextView dans AppKit. Cet exemple de filtrage de libellés ne nécessite aucun moteur de mise en page personnalisé. Introduisez TextKit lorsque les exigences de modification ou de mise en page du texte d’une ligne le justifient ; gérez les éléments de la collection et les transitions de ligne dans l’interface SwiftUI qui l’entoure. TextKit
Commencez par la séquence de quatre enregistrements et vérifiez l’identité et les éléments présents avant de régler les animations. Une fois que le remplacement avec un nombre identique et les résultats vides se comportent comme prévu, adaptez la conception des lignes et la source de données tout en conservant des identifiants stables et en reliant le déclencheur d’animation à la collection affichée.
Sources
- Animations — Apple
- ForEach — Apple
- Identifiable — Apple
- Demystify SwiftUI — Apple
- VStack — Apple
- ScrollView — Apple
- App — Apple
- WindowGroup — Apple
- animation(_:value:) — Apple
- AnyTransition — Apple
- asymmetric(insertion:removal:) — Apple
- easeInOut(duration:) — Apple
- accessibilityReduceMotion — Apple
- LazyVStack — Apple
- NSHostingView — Apple
- TextKit — Apple