SwiftUI n’est pas React
Je cherchais useState. Il n’y a pas de useState.
Quand j’ai commencé Renala, j’avais des années de React dans la tête : interface déclarative, état local, DOM virtuel qui calcule un diff avant de mettre à jour le vrai DOM. Je m’attendais à retrouver la même mécanique, simplement habillée en Swift, avec des Views à la place des composants, des property wrappers à la place des hooks, et le moteur de réconciliation d’Apple à la place de celui de React.
J’avais raison sur la surface, et tort sur l’architecture.
Ce qui comptait, ce n’était pas la syntaxe, mais quatre questions très concrètes : où vit l’information qui change, comment elle devient des pixels, comment ces pixels retrouvent du sens pour VoiceOver, et quel contexte survit à la navigation. MVVM répondait à une partie du problème. Pas à tout.
Le modèle mental de React
Le modèle mental de React est familier : une fonction de composant lit des props et un état local, renvoie de l’interface, puis s’exécute à nouveau quand l’état change. La nouvelle sortie est comparée à l’ancienne, puis le DOM est corrigé.
Tout cela se câble explicitement avec useState, useEffect et useMemo.
Un comportement par défaut à connaître : quand un composant parent se réévalue, ses enfants se réévaluent aussi, que les props aient changé ou non. React.memo est l’opt-in qui fait de la comparaison des props le critère de filtrage ; useMemo stabilise des valeurs calculées mais n’empêche pas le composant lui-même d’être réévalué.12
SwiftUI fonctionne autrement.
En SwiftUI, une View est une struct jetable, donc une valeur. SwiftUI garde l’identité et le stockage de l’état ailleurs, puis redemande son body à la vue quand il lui faut une nouvelle description. La struct de vue n’est pas l’endroit où l’état doit vivre.
L’état vit ailleurs.
Vue d’ensemble
Une architecture d’interface n’a rien de mystérieux. Elle doit répondre à quelques questions pratiques.
- Où vit l’information qui change ? Si l’application ne sait pas clairement comment retenir l’avancement du scan, le fichier sélectionné ou les dossiers ouverts, les mises à jour deviennent imprévisibles.
- Qu’est-ce que l’application dessine vraiment ? Certaines mises à jour coûtent peu, d’autres non. Un petit formulaire de réglages et un treemap avec des dizaines de milliers de rectangles n’ont pas le même budget de rendu.
- Quel sens les pixels portent-ils ? Un utilisateur voyant peut déduire du sens à partir de la forme, de la couleur et de la position. VoiceOver ne le peut pas. Il lui faut des labels, des rôles, des actions et un ordre de focus.
- Quel contexte survit à la navigation ? Quand l’utilisateur entre dans un fichier ou un dossier, l’application doit décider ce qui reste visible pour éviter qu’il se perde.
Renala a rendu ces quatre questions incontournables. L’état du scan devait vivre quelque part de stable. La barre latérale et le treemap devaient rester réactifs à des échelles très différentes. Le treemap dessiné sur mesure devait redevenir compréhensible pour les technologies d’assistance. Et la navigation devait préserver le contexte d’une manière qui ressemble à une application Mac, pas à un écran de téléphone.
La suite de l’article est simplement la réponse de Renala à ces quatre questions.
Couche ① : Où vit l’information qui change
L’état, c’est simplement l’information que l’interface peut modifier et doit retenir : l’avancement du scan, la racine courante, le nœud sélectionné, les dossiers ouverts, les filtres et les tags. Si cette mémoire se trouve au mauvais endroit, les mises à jour deviennent fragiles.
La réponse moderne, c’est @Observable. Pendant que SwiftUI évalue le body d’une vue, il enregistre les propriétés observables que cette vue lit. Plus tard, une mutation peut invalider les vues qui dépendaient de ces propriétés, au lieu de traiter toute mutation comme équivalente.
Ni abonnements, ni tableaux de dépendances, ni nettoyage. Il suffit d’annoter une classe, et SwiftUI suit les lectures. La session WWDC 2023 Discover Observation in SwiftUI explique le mécanisme en détail.
// Simplifié pour l'illustration
@Observable @MainActor
final class ScanViewModel {
var scanState: ScanState = .idle
var currentRoot: FileNode? = nil
}
Une vue qui lit scanViewModel.scanState dépend de scanState ; une vue qui ne lit que currentRoot dépend de currentRoot. SwiftUI dispose ainsi d’une invalidation bien plus fine que « quelque chose a changé dans l’objet ». (L’ancien protocole ObservableObject fonctionne à la granularité de l’objet et invalide toutes les vues dès qu’une propriété @Published change. @Observable suit au niveau de chaque propriété.)
C’est plus net que useSelector dans Redux ou que React.memo avec un comparateur personnalisé. Le framework se charge du reste.
La réponse de Renala : MVVM pour les objets qui portent l’état
Dans Renala, MVVM désigne simplement la couche qui possède l’état. Si vous avez déjà utilisé Redux ou MobX, la logique est familière :
- Le Model, c’est
FileNode,ScanResult,VolumeInfo. De simples données. Aucune dépendance à l’interface. - Le ViewModel, c’est
ScanViewModeletTreemapViewModel:@Observable,@MainActor. Ils détiennent l’état de l’application et l’exposent aux vues. Ils gèrent les scans, calculent les dispositions, traitent les actions utilisateur. - La View lit le ViewModel et lui renvoie des actions.
La vue lit des propriétés du ViewModel dans body, SwiftUI enregistre ces dépendances, puis les mises à jour ultérieures peuvent s’organiser autour d’elles. Pas de câblage, et bien moins de risques de créer soi-même des bugs de stale closure.
@MainActor signale que cet état appartient au thread principal, celui sur lequel SwiftUI effectue le rendu. Les mutations d’interface se retrouvent ainsi dans un endroit connu et sûr. Le compilateur vérifie les franchissements de frontière à la compilation (le précédent article couvre le modèle de concurrence complet) ; le travail d’arrière-plan renvoie encore ses résultats explicitement, mais le modèle d’isolation gagne en clarté.
C’est là que vit l’état durable. La question suivante est plus simple et plus concrète : qu’est-ce que cette application dessine exactement, et combien de travail chaque mise à jour peut-elle se permettre avant que l’interface ne ralentisse ?
Couche ② : Ce que l’application dessine vraiment
Le rendu, c’est transformer de l’état en quelque chose de visible, et toute interface a un budget. Renala réunissait deux problèmes de rendu distincts dans une seule application : la barre latérale devait représenter un arbre immense sans créer une hiérarchie de vues immense, et le treemap devait dessiner des dizaines de milliers de rectangles sans retenir des dizaines de milliers de vues enfants.
Contrairement à React, SwiftUI peut souvent sauter l’évaluation du body d’un enfant quand ses propriétés stockées n’ont pas changé. Il suit les propriétés observables que chaque vue lit et utilise ce suivi pour affiner encore l’invalidation. Ce modèle a ses cas limites, notamment autour des closures et de l’identité. L’annexe B couvre la mécanique de mise à jour en détail ; en résumé, passer des références stables de ViewModel plutôt que des closures fraîchement recréées a permis à SwiftUI d’éviter davantage de travail inutile dans Renala.
Premier point de pression : l’arborescence de la barre latérale
L’implémentation évidente de la barre latérale est un DisclosureGroup récursif. C’est aussi la mauvaise. Avec un arbre de 5,2 millions de nœuds, l’imbrication récursive crée la mauvaise frontière de virtualisation. Ouvrez un répertoire qui contient des milliers de descendants, et l’application cesse d’être une application.
La solution, c’est une List plate pilotée par un tableau précalculé de nœuds visibles. Déplier un répertoire insère ses enfants au bon index. Le replier les retire. SwiftUI ne crée alors des lignes que pour le viewport visible.
C’est de l’architecture par projection. Le système de fichiers reste récursif. L’interface devient une projection plate de la tranche visible, parce que c’est cette forme que SwiftUI sait virtualiser à faible coût. La contrepartie, c’est tout le travail de synchronisation : le ViewModel possède désormais à la fois l’arbre récursif et la projection plate de visibilité, et chaque mutation doit garder les deux alignés.

List plate, pas un arbre récursif. Pendant la recherche, le tableau visible est filtré aux nœuds correspondants et à tous leurs ancêtres. Vider la recherche restaure l’état d’expansion précédent.Deuxième point de pression : la surface du treemap
Le treemap posait un problème d’un tout autre ordre.
Un scan complet du disque produit des dizaines de milliers de rectangles visibles. Des vues SwiftUI Rectangle individuelles ont figé l’application. Metal pourrait encore surpasser tout le reste, mais il aurait aussi imposé un saut de périmètre bien plus grand. Canvas était le compromis le plus réaliste : du dessin en mode immédiat à l’intérieur de SwiftUI.
C’est là que l’article bascule architecturalement. Canvas n’est pas un rectangle plus rapide. C’est un autre contrat. Les contrôles SwiftUI standard conservent pour vous la structure et la sémantique. Le mode immédiat réduit le coût de rendu et donne plus de contrôle, mais la facture retombe ailleurs.
Canvas { context, size in
// Une image précalculée couvre tout le canvas : ombrage en coussin de toutes les cellules
context.draw(cushionImage, in: CGRect(origin: .zero, size: size))
// Puis les surcouches par rectangle : en-têtes de répertoires, labels, badges
for rect in treemapViewModel.rects {
drawOverlays(context: context, rect: rect)
}
}
L’implémentation réelle compose quatre couches :
Un seul nœud Canvas dans la hiérarchie. La surbrillance de survol et de sélection est gérée par une surcouche légère distincte, pour éviter de redessiner tout le Canvas à chaque mouvement de souris.

L’autre prix à payer, c’est l’accessibilité. Les vues SwiftUI standard participent automatiquement à l’arbre d’accessibilité : un Button obtient un label VoiceOver (le lecteur d’écran intégré de macOS), une ligne de List peut être parcourue au clavier. Un Canvas n’expose pas automatiquement ses éléments dessinés comme éléments d’accessibilité individuels. Les lecteurs d’écran ne voient pas les pixels : c’est la sémantique qu’ils parcourent. Dès qu’on quitte les contrôles retenus pour dessiner directement des pixels, cette sémantique disparaît, sauf si on la reconstruit soi-même.
Couche ③ : Ce que les pixels ne disent pas
Les pixels peuvent montrer une forme, une couleur, une profondeur et une position. Ils ne peuvent pas, à eux seuls, dire à un lecteur d’écran ce qu’est un élément, comment il s’appelle, quelle action il propose, ni où il se situe dans l’ordre du focus. C’est ce manque de sens que j’appelle ici la sémantique.
La solution consiste à ajouter une surcouche invisible, en parallèle. Le Canvas dessine le treemap. Par-dessus, un ZStack transparent contient un rectangle Color.clear pour chaque cellule visible du treemap, positionné et dimensionné pour correspondre exactement à la disposition du Canvas. Le Canvas est marqué .accessibilityHidden(true), pour que VoiceOver l’ignore complètement.
Ce n’est pas du nettoyage d’accessibilité, c’est une seconde architecture d’interface, en parallèle de la première. Gardez les deux couches alignées, sinon l’application reste cohérente à l’œil, mais devient incohérente du point de vue sémantique.
Chaque élément de la surcouche porte quatre informations sémantiques :
accessibilityLabel: le nom du fichier ou du répertoire, son type, et pour un répertoire, le nombre d’enfants. Un fichier se lit comme « photo.jpg, JPG » ; un répertoire comme « Library, dossier, 342 éléments ».accessibilityValue: la taille formatée, le pourcentage du parent et les badges de nettoyage éventuels. « 1,2 Go, 8 %, marqué Volumineux ».accessibilityHint: ce qui se passera à l’activation. « Sélectionne ce fichier » pour les fichiers, « Ouvre ce répertoire » pour les répertoires.3accessibilitySortPriority: définie proportionnellement à la taille du fichier sur disque, pour que VoiceOver parcoure d’abord les plus gros éléments, le même ordre de priorité visuelle que le treemap exprime par la surface.
Les actions d’accessibilité personnalisées ne se limitent pas à la navigation. Chaque élément expose des actions pour sélectionner, entrer dans un répertoire, taguer un fichier (Ancien, Temporaire, Volumineux) et l’ajouter au lot de nettoyage. Elles correspondent aux mêmes opérations qu’à la souris et au clavier.
// Simplifié depuis TreemapAccessibilityOverlay.swift
ZStack {
ForEach(visibleRects, id: \.nodeID) { rect in
Color.clear
.frame(width: rect.frame.width, height: rect.frame.height)
.position(x: rect.frame.midX, y: rect.frame.midY)
.accessibilityLabel(label(for: rect))
.accessibilityValue(value(for: rect))
.accessibilityHint(hint(for: rect))
.accessibilitySortPriority(sortPriority(for: rect))
.accessibilityAction(named: String(localized: "Sélectionner")) { select(rect) }
.accessibilityAction(named: String(localized: "Taguer Volumineux")) { tag(rect, .large) }
}
}
.accessibilityElement(children: .contain)
Un composant séparé publie des notifications d’accessibilité pour les changements d’état sans déplacer le focus visuel, comme la fin d’un scan, un changement de filtre ou une opération par lot, afin que les utilisateurs VoiceOver restent informés sans devoir revenir en arrière.
Ce motif, un Canvas visuel masqué de l’arbre d’accessibilité et une surcouche sémantique parallèle, se réutilise partout où l’on recourt au dessin en mode immédiat dans SwiftUI. Le coût d’implémentation est réel : une seconde passe de disposition, ainsi que le maintien de la parité entre l’ordre de dessin du Canvas et les positions de la surcouche. Il faut en tenir compte dès le départ : si vous adoptez ce motif, prévoyez des tests ou des outils d’inspection pour repérer les dérives de parité quand le Canvas évolue. « On ajoutera l’accessibilité plus tard » est le « on écrira les tests plus tard » du développement d’interface.
Couche ④ : Ce que la navigation doit préserver
Les trois premières couches sont des problèmes de framework avec des réponses de framework. Celle-ci est un problème de conception. Ni équivalent à Canvas, ni property wrapper, ni interactif à activer. La question est plus simple et plus difficile : que doit encore voir l’utilisateur après s’être déplacé ?
La navigation, ce n’est pas seulement aller quelque part. C’est décider quel contexte survit au déplacement. Si trop d’éléments disparaissent, l’utilisateur perd ses repères et doit reconstruire mentalement où il se trouve.
Vers la fin du projet, un ami a testé l’application et repéré d’emblée deux ratés de navigation : sélectionner un fichier faisait s’effondrer l’arbre d’une manière désorientante, et le fil d’Ariane utilisait une flèche qui avait l’air cliquable alors qu’elle ne l’était pas.
Les deux relevaient de la conception, pas du framework. Les corrections ont été tout aussi concrètes : revoir la navigation en zoom pour préserver le contexte quand on entre dans un fichier, et supprimer la flèche trompeuse du fil d’Ariane. La navigation au clavier suivait la même logique : les flèches pour parcourir l’arbre, Retour arrière pour remonter, Espace pour Quick Look.
C’est de l’architecture, pas de la décoration. Sélection, drill-down, fil d’Ariane, focus dans la barre latérale, déplacements au clavier : tout cela décrit des transitions d’état auxquelles la plateforme attache des attentes. Sur macOS, la navigation n’est pas un détail décoratif. Elle façonne fortement la quantité de contexte que l’interface doit préserver.
Pour paraphraser Steve Krug dans Don’t Make Me Think : chaque question supplémentaire dans la tête de l’utilisateur ajoute de la friction. Une flèche de fil d’Ariane qui ne répond pas au clic est un point d’interrogation. Un arbre qui disparaît quand on sélectionne un fichier en est un autre. La correction, dans les deux cas, n’était pas de répondre à la question, mais de la supprimer.
// Simplifié : le drill-down préserve l'état de la barre latérale
struct ContentView: View {
@State private var selectedNode: FileNode?
@Bindable var viewModel: ScanViewModel
var body: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel, selection: $selectedNode)
} detail: {
if let node = selectedNode {
TreemapView(root: node, viewModel: viewModel)
}
}
}
}
L’état de sélection (selectedNode) vit dans la View tandis que l’état de l’arbre vit dans le ViewModel : naviguer dans un nœud change le volet de détail sans effondrer la barre latérale. C’est la correction du bug rapporté par l’ami : garder les deux types d’état dans des propriétaires différents pour qu’une action de navigation ne supprime pas l’autre.
SwiftUI sur macOS n’est pas SwiftUI sur iOS. Le framework est le même. Les attentes de la plateforme, elles, changent.
Ce que MVVM explique, et ce qu’il masque
MVVM explique encore quelque chose de réel ici. Il dit où vit l’état partagé durable dans Renala, et pourquoi elle fait cohabiter des View structs jetables et des types de référence qui possèdent l’état.
Mais MVVM est trop grossier pour expliquer toute l’interface. Il ne dit presque rien sur la projection aplatie de la barre latérale, le basculement vers le dessin en mode immédiat, la surcouche sémantique, ni les règles de navigation imposées par les attentes de macOS.
C’est la vraie leçon. Dans Renala, l’architecture n’a jamais été une seule chose. C’était un assemblage de décisions sur la propriété de l’état, le régime de rendu, la couche sémantique et le modèle de navigation. MVVM n’était qu’une étiquette utile dans cet ensemble, pas l’ensemble lui-même.
Pour aller plus loin
Sessions WWDC (gratuites, officielles) :
- Discover Observation in SwiftUI :
@Observableet le nouveau modèle d’observation. - Demystify SwiftUI : ce que SwiftUI réévalue, et pourquoi.
- Add rich graphics to your SwiftUI app : l’API
Canvasen pratique. - SwiftUI on iPad: Organize your interface : des schémas de navigation qui comptent aussi sur macOS.
Référence écrite :
Le pipeline de rendu du treemap, y compris l’ombrage de Lambert qui donne aux rectangles leur aspect tridimensionnel, est couvert dans le prochain article.
Footnotes
-
C’est l’une des idées reçues les plus répandues sur React. Sans
React.memo, un composant est réévalué à chaque réévaluation de son parent, même si toutes ses props sont identiques. Le changement de props ne joue aucun rôle dans le comportement par défaut.React.memoest l’opt-in qui fait de la comparaison des props le critère de filtrage. ↩ -
React.memoetuseMemoservent des objectifs différents.React.memoenveloppe un composant et saute le rendu quand les props sont superficiellement égales.useMemomémoïse une valeur calculée à l’intérieur d’un rendu ; il n’empêche pas le composant lui-même d’être réévalué. On peut utiliseruseMemopour maintenir une valeur référentiellement stable qui aide ensuiteReact.memoen aval, maisuseMemoseul n’est pas un mécanisme de prévention de la réévaluation. ↩ -
Les Human Interface Guidelines d’Apple déconseillent d’inclure des noms de gestes dans les hints d’accessibilité, car les gestes diffèrent selon les plateformes et les modes de saisie. Sur macOS, l’activation VoiceOver se fait via VO+Espace, pas par « toucher deux fois », qui est un geste iOS. Les hints doivent décrire des résultats, pas des gestes. ↩
