Pour modifier une liste SwiftUI filtrée sans changer le mauvais élément, conservez un tableau d’origine et reliez chaque ligne visible à celui-ci au moyen d’un identifiant stable. Cet exemple macOS vous permet de filtrer, renommer, activer ou désactiver et supprimer des libellés synthétiques dans votre propre app.
Pourquoi le filtrage seul ne crée pas de lignes modifiables
La méthode filter(_:) de Swift renvoie une nouvelle collection contenant les éléments correspondants dans leur ordre d’origine. Elle ne crée pas de relation modifiable entre cette collection et sa source. Avec un tableau de structures, modifier un élément d’un tableau filtré stocké séparément ne met pas à jour le tableau d’origine. Ces conclusions découlent du comportement documenté de filter(_:) et de la sémantique des valeurs des structures de Swift.
Par exemple, supposons que le tableau d’origine contienne Grid, Tile et Ring, mais qu’un filtre n’affiche que Ring. Sa position visible est zéro ; sa position d’origine est deux. Utiliser la position visible pour écrire dans le tableau d’origine modifierait Grid. Le tri des résultats affichés peut produire le même décalage.
Une liaison fournit la connexion de lecture et d’écriture manquante. Apple décrit Binding comme une connexion à des données détenues ailleurs et fournit init(get:set:) pour créer un accès personnalisé. Ici, les deux closures recherchent l’élément d’origine par identifiant à chaque exécution. La collection filtrée détermine les lignes affichées ; le tableau d’origine reçoit les modifications. Consultez Binding et init(get:set:).
Choisir une identité stable avant de créer l’éditeur
Chaque élément doit avoir un identifiant qui reste inchangé lorsque son nom, son paramètre d’inclusion ou sa position dans le tableau change. Le protocole Identifiable d’Apple décrit l’identité stable d’une entité, tandis que ForEach utilise des données identifiables ou un identifiant explicite pour distinguer les vues qu’il génère. Consultez Identifiable et ForEach.
L’exemple crée chaque UUID une seule fois dans une propriété stockée let id. Renommer un libellé ne modifie pas son identifiant. Évitez de dériver l’identité du nom modifiable ou de générer un nouvel UUID à chaque lecture de l’identifiant. Veillez aussi à ce que les éléments distincts aient des identifiants différents : la recherche trouve délibérément le premier identifiant correspondant, donc les doublons rendraient la cible d’écriture ambiguë.
Utilisez ce guide pour choisir le flux de données :
| Exigence de l’interface | Approche de liaison |
|---|---|
| Afficher une collection de valeurs sans la modifier | Transmettre les valeurs à des lignes en lecture seule. |
| Modifier directement la collection d’origine | Utiliser, lorsque c’est approprié, la prise en charge des liaisons de collection de SwiftUI. |
| Modifier des résultats dont les positions diffèrent de celles de la source | Résoudre l’élément d’origine au moyen d’un identifiant stable. |
| Garder un éditeur ouvert après la disparition de son élément des résultats | Définir pour l’éditeur sa propre sélection et sa propre règle de durée de vie. |
La suite implémente le troisième cas. Elle définit aussi ce qui se passe lorsqu’une ligne disparaît, afin qu’un élément absent ne provoque jamais un accès à un indice hors limites.
Créer un exemple macOS complet
Dans Xcode, créez un projet d’app macOS utilisant SwiftUI et définissez, pour cet exemple, la cible de déploiement sur macOS 13 ou une version ultérieure. Remplacez les déclarations d’app et de vue de contenu générées par le fichier unique suivant. Conservez exactement un point d’entrée @main ; la documentation App d’Apple décrit cette exigence et la structure WindowGroup utilisée ici.
Le parent possède le tableau d’origine dans @State privé ; chaque enfant reçoit un @Binding. Cela suit le modèle documenté par Apple pour partager l’état avec des sous-vues. L’exemple ne contient que des libellés synthétiques locaux et conserve ses données en mémoire.
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)
}
}
}
Il s’agit d’un exemple d’app complet, et non d’un fragment à coller dans une vue existante. Il a été vérifié par rapport à la documentation des API consultée, mais n’a pas été compilé ici, car cet environnement ne dispose ni d’une chaîne d’outils Swift ni du SDK macOS.
Comment une modification atteint le tableau d’origine
La propriété calculée visibleItems recalcule l’appartenance à partir du tableau d’origine et des paramètres de filtrage actuels. ForEach reçoit ces résultats et crée une ligne pour chaque élément correspondant. Filtrer avant de construire les lignes évite également d’intégrer dans chaque itération une branche conditionnelle qui produit zéro ou une ligne, un modèle abordé dans les conseils d’Apple sur les performances de ForEach.
Quand une ligne lit sa liaison, le getter recherche son identifiant dans le tableau d’origine actuel. L’instantané ne sert que de valeur de repli pour un élément qui a déjà été supprimé. Tant que l’élément existe, le getter renvoie la valeur actuelle dans le tableau d’origine, et non l’instantané capturé lors de la création de la ligne.
Lorsque l’utilisateur modifie Label name, $item.name projette la liaison de l’élément sur sa propriété modifiable name. Apple décrit cette projection de propriété au moyen de l’indice à membre dynamique de Binding. Le setter recherche ensuite l’indice actuel de l’élément dans le tableau d’origine et met à jour ses champs modifiables, tout en préservant son identifiant stocké.
La recherche utilise firstIndex(where:), qui renvoie un indice facultatif et renvoie nil en l’absence de correspondance. Le code vérifie le résultat avant d’accéder au tableau par indice. Comme la recherche est répétée à chaque écriture, inverser le tableau d’origine ne laisse pas le setter utiliser un ancien indice. Consultez la documentation d’Apple sur firstIndex(where:).
Que se passe-t-il lorsqu’une ligne disparaît ?
Il y a deux cas différents. Si une modification fait qu’un élément ne satisfait plus au filtre, il existe toujours dans le tableau d’origine. La modification est conservée, mais la ligne quitte les résultats. Par exemple, désactiver Included lorsque Included only est activé masque cette ligne ; désactiver le filtre la fait réapparaître.
Si l’élément a été supprimé du tableau d’origine, toute écriture ultérieure par une ancienne liaison est rejetée. L’exemple affiche Modification ignorée : le libellé a été supprimé. Il ne réinsère pas l’instantané dans le tableau. Renvoyer un instantané par le getter et rejeter les écritures visant un élément absent sont des règles explicites de cet exemple, et non des garanties fournies par SwiftUI.
Le filtrage en direct par nom nécessite aussi un choix d’interaction. Apple indique qu’un TextField lié à une chaîne met sa valeur à jour en continu pendant la saisie. Par conséquent, une ligne peut disparaître dès que son nouveau nom ne correspond plus à la requête. Pour renommer sans interruption, effacez d’abord la requête ou concevez un éditeur distinct qui valide un brouillon à la suite d’une action explicite. Consultez TextField.
Vérifier le comportement avec un bref contrôle sur ordinateur
Les vérifications suivantes décrivent le comportement attendu d’après l’exemple ; elles ne correspondent pas à des résultats d’exécution rapportés. Démarrez une nouvelle exécution pour retrouver les libellés initiaux, puis effectuez les étapes dans l’ordre.
| Action | Résultat attendu |
|---|---|
Saisissez Ring dans Filter labels. |
Seul Ring apparaît. |
Remplacez son nom par Ring Two. |
L’affichage du tableau d’origine devient Grid · Tile · Ring Two. |
Effacez la requête, puis cliquez sur Reverse original order. |
L’affichage devient Ring Two · Tile · Grid. |
Remplacez Grid par Grid Two. |
Seule la valeur de cet élément dans le tableau d’origine change. |
Activez Included only, puis désactivez Included pour Grid Two. |
Cette ligne disparaît, mais son nom reste dans l’affichage du tableau d’origine. |
Désactivez Included only. |
Grid Two réapparaît avec son interrupteur désactivé. |
Saisissez Tile, puis cliquez sur son bouton Remove. |
Sa ligne et son entrée dans le tableau d’origine disparaissent toutes les deux. |
Si le mauvais élément change, vérifiez si un décalage d’affichage est utilisé comme indice dans le tableau d’origine. Si les modifications disparaissent après l’effacement du filtre, vérifiez si la ligne possède une copie distincte dans @State. Si une ligne renommée se comporte comme un nouvel élément, vérifiez si son identifiant dépend d’un contenu modifiable. Ces vérifications portent sur les trois relations que l’exemple explicite : la propriété de la source, la destination d’écriture et l’identité de la ligne.
Respecter les limites de macOS et des API
Pour de courts libellés modifiables, TextField de SwiftUI fournit l’interface nécessaire. Un éditeur de documents plus complet peut plutôt nécessiter NSTextView d’AppKit, qui prend en charge le texte enrichi et s’intègre à des composants du système de texte tels que NSTextStorage et NSTextLayoutManager. Le contrôle d’édition change ; l’application doit toujours décider quel élément du modèle d’origine reçoit chaque modification. Consultez NSTextView.
Le mécanisme pris en charge pour intégrer une vue AppKit dans une hiérarchie SwiftUI est NSViewRepresentable. Apple documente la création et la mise à jour de la vue représentée, ainsi que l’utilisation d’un coordinateur pour transmettre les interactions. Pour un futur éditeur personnalisé, conservez la même cible d’écriture fondée sur un identifiant stable. Laissez SwiftUI gérer la mise en page de la vue représentée, au lieu de modifier directement son cadre ou ses limites gérés. Consultez NSViewRepresentable.
Une extension AppKit implique aussi les limites de TextKit : Apple indique que l’accès à NSTextView.layoutManager peut faire passer la vue en mode de compatibilité utilisant NSLayoutManager. Vérifiez la disponibilité et le comportement des API de texte concernées avant de les adopter. La solution de liaison à une collection ne nécessite aucune personnalisation de TextKit. Consultez la discussion sur la compatibilité dans NSTextView.
Pour les collections plus volumineuses, tenez compte du coût des recherches. Apple indique que filter(_:) et firstIndex(where:) ont une complexité linéaire ; les recherches répétées pour chaque ligne peuvent donc devenir coûteuses à mesure que la collection grandit. Mesurez votre interface avant d’introduire un cache associant identifiants et indices, puis reconstruisez ce cache chaque fois qu’une modification structurelle change les indices. Consultez filter(_:) et firstIndex(where:).
Comme dans l’exemple, gardez l’accès à l’interface utilisateur depuis ces liaisons calculées sur l’acteur principal. La documentation d’Apple sur l’initialiseur de liaison explique que les accès autorisés d’une liaison calculée dépendent de l’isolation de ses closures. Transmettre une liaison ailleurs ne supprime pas ces restrictions. Consultez init(get:set:).
Pour cet éditeur de libellés filtrés, conservez un tableau d’origine, utilisez des identifiants stables pour les lignes et résolvez la cible d’écriture au moment de la modification. Choisissez ensuite délibérément le comportement à la disparition : un élément filtré conserve sa modification, tandis qu’un élément supprimé rejette toute nouvelle écriture en affichant une explication.
Sources
- filter(_:) — Apple
- Structures and Classes — Swift.org
- Binding — Apple
- init(get:set:) — Apple
- Identifiable — Apple
- ForEach — Apple
- App — Apple
- State — Apple
- firstIndex(where:) — Apple
- TextField — Apple
- NSTextView — Apple
- NSViewRepresentable — Apple