Fiche 00.08 — De UIKit à SwiftUI : le pont mental
Objectif
Faire basculer ton cerveau d'UIKit (impératif, objets vivants, tu modifies des vues) vers SwiftUI (déclaratif, structs jetables, tu décris un état). C'est LA fiche-charnière : surtout des tables de correspondance pour retrouver tes réflexes au bon endroit.
1. Le changement de modèle mental : View = f(state)
En UIKit, tu modifies des objets vivants :
En SwiftUI, tu décris ce que l'écran doit être pour un état donné, et le framework recalcule l'UI :
La formule à graver : l'UI est une fonction pure de l'état. Tu ne touches jamais une vue à la main ; tu changes l'état, et la vue se recalcule.
Réflexe à tuer : chercher « comment je récupère une référence à ce label pour changer son texte ». Tu ne récupères rien. Tu changes l'état dont dépend le texte.
2. La View est une struct jetable (vs UIViewController objet vivant)
| UIKit | SwiftUI |
|---|---|
UIView / UIViewController = classe (référence), instance vivante que tu gardes et mutes | View = struct (valeur), recréée à chaque recalcul |
Tu gardes un self pendant tout le cycle de vie | La struct est créée, lue (body), jetée. En permanence. |
| Stocker une référence est normal | Stocker une référence vers une struct body n'a aucun sens |
body est appelé des dizaines/centaines de fois. Une View SwiftUI ne doit donc rien faire de coûteux dans body ni dans son init. L'état persistant ne vit pas dans la struct : il vit dans @State, @Observable, etc., que SwiftUI conserve en dehors de la struct.
Question piège : « où est stocké
countsi la struct est recréée ? » → Dans un storage géré par SwiftUI, associé à l'emplacement de la vue dans l'arbre.@Staten'est qu'un pointeur vers ce storage.
3. Table — cycle de vie
| UIKit | SwiftUI | Notes |
|---|---|---|
viewDidLoad | .task { } ou .onAppear { } | Pas de phase « load » séparée : la struct n'a pas de vie longue |
viewWillAppear | .onAppear { } | Appelé à chaque apparition |
viewDidAppear | .onAppear { } | SwiftUI ne distingue pas will/did |
viewWillDisappear / viewDidDisappear | .onDisappear { } | |
chargement async dans viewDidLoad + annuler dans deinit | .task { await load() } | s'annule tout seul au démontage |
deinit | (pas d'équivalent direct sur la View) | Le nettoyage se fait via l'annulation des .task / cancellation |
Le point qui change tout : .task est lié au cycle de vie de la vue. Quand la vue disparaît, la Task est annulée automatiquement (coopératif : tes await lèvent CancellationError). En UIKit, tu devais annuler manuellement ton URLSessionTask dans viewWillDisappear/deinit.
.task(id:): relance l'action quandidchange (équivalent d'un « refetch quand l'argument change »).
4. Table — communication / data flow
C'est ici que ton instinct UIKit est le plus dépaysé. Oublie les delegates pour faire remonter de l'info.
| Besoin | UIKit | SwiftUI / Swift moderne |
|---|---|---|
| Enfant → parent (callback ponctuel) | delegate (protocole + weak var delegate) | closure passée en paramètre |
| Partager un état observable | KVO, didSet, NotificationCenter | @Observable (macro Observation) |
| Lier un champ ↔ une donnée | target-action + lire .text | @Binding / $value (two-way) |
| Diffuser un événement global | NotificationCenter | @Observable injecté en @Environment, ou parfois encore NotificationCenter |
| Réagir à un bouton | addTarget(_:action:) | closure dans Button("...") { } |
ViewModel moderne (Observation, pas ObservableObject/@Published) :
Enfant → parent par closure (l'équivalent direct de ton vieux delegate) :
Two-way binding ($) — l'équivalent du target-action qui lit/écrit un champ :
Erreur fréquente d'un dev UIKit : recréer un
protocol XDelegatepour chaque interaction. En SwiftUI, une closure suffit dans 90 % des cas.
5. Table — layout
| UIKit | SwiftUI |
|---|---|
Auto Layout (NSLayoutConstraint, anchors) | VStack / HStack / ZStack + modifiers (.padding, .frame, .layoutPriority) |
IBOutlet / IBAction | (disparus) — données via @State/bindings, actions via closures |
| Storyboard / XIB | Le code est la source de vérité |
| Voir le rendu = build + run sur simulateur | #Preview (rendu live, plusieurs états côte à côte) |
addSubview + contraintes | imbrication déclarative dans body |
UIStackView | VStack / HStack (mêmes idées, sans config impérative) |
safeAreaLayoutGuide | géré par défaut, .ignoresSafeArea() pour sortir |
Le #Preview remplace l'aller-retour build/run et te laisse afficher plusieurs configurations d'un coup :
.constant("..."): un binding bidon pour les previews, quand tu n'as pas de@Stateparent sous la main.
6. Table — navigation
| UIKit | SwiftUI |
|---|---|
navigationController?.pushViewController(vc, animated: true) | NavigationStack { NavigationLink(...) } |
| Push programmatique | NavigationStack(path: $path) + path.append(route) |
present(vc, animated: true) (modal par-dessus) | .sheet(isPresented:) ou .sheet(item:) |
present plein écran (.fullScreen) | .fullScreenCover(isPresented:) |
dismiss(animated:) | @Environment(\.dismiss) puis dismiss() |
UITabBarController | TabView { ... .tabItem { } } |
| segues (storyboard) | (disparus) — tout est explicite en code |
Navigation programmatique (l'équivalent moderne d'un UINavigationController que tu pilotes) :
Tu manipules un tableau d'état (path) au lieu d'appeler push/pop. path.removeLast() = pop ; path = [] = pop to root.
7. Impératif → déclaratif : tu ne « rafraîchis » plus jamais à la main
| UIKit (tu déclenches le redraw) | SwiftUI (le state le déclenche) |
|---|---|
setNeedsLayout() / layoutIfNeeded() | rien — change l'état |
tableView.reloadData() | change le tableau source ; List/ForEach se met à jour |
label.text = ... | Text(state) se recalcule |
view.isHidden = true | if condition { ... } dans body |
setNeedsDisplay() | rien — recompose sur changement d'état |
C'est le piège mental n°1 du dev UIKit : chercher la fonction « reload ». Il n'y en a pas. La règle : si l'UI ne se met pas à jour, c'est que ton state n'est pas observable ou pas modifié (@State, @Observable, @Binding mal câblés), pas qu'il manque un reload().
8. Pourquoi une struct SwiftUI ne crée PAS de retain cycle
En UIKit tu écris [weak self] par réflexe dans chaque closure, parce que tes vues sont des classes qui se capturent mutuellement (vue → closure → vue).
Une View SwiftUI est une struct = type valeur. Une struct n'est pas « retenue » par ARC comme une classe : elle est copiée. Capturer self (la struct) dans le closure d'un Button ne crée donc aucun cycle de référence.
Mais [weak self] reste utile et nécessaire dès qu'une classe est dans la boucle :
Règle de tri :
- closure dans le
bodyd'uneView(struct) → pas de[weak self]. - closure stockée/long-lived dans une classe (timer, Combine, NotificationCenter, callback gardé) →
[weak self]comme avant.
Subtilité Swift moderne : dans une
Task { }non stockée, tu n'as souvent pas besoin de[weak self]— la Task se termine et libère la capture. Le danger, c'est la closure retenue durablement par une classe.
Points à connaître
- Ne cherche pas
reload()/setNeeds...: tu modifies l'état, SwiftUI recompose. Si rien ne bouge, ton state n'est pas observable (oubli de@Observable/@State) ou tu as muté une copie. - Ne mets pas de logique lourde dans
bodyni dans l'initd'une View :bodytourne en boucle. Le travail va dans.task/.onAppearou dans le store@Observable. - Arrête les delegates pour remonter de l'info : une closure (
let onSelect: (T) -> Void) remplace 95 % de tes protocoles delegate. [weak self]n'est pas systématique : inutile dans lebodyd'une struct, toujours pertinent dans une classe à closure long-lived..tasks'annule tout seul au démontage : ne réimplémente pas la gestion d'annulation que tu faisais dansviewWillDisappear/deinit.
Exercice
Voici un UIViewController en pseudo-UIKit. Traduis-le en View SwiftUI et liste, pour chaque ligne, son équivalent.
Objectif (10-20 min) : produire une View avec un store @Observable (users, load() async), un .task pour le chargement initial, une List sans reloadData, un Button « Rafraîchir », et écrire en commentaire la correspondance UIKit → SwiftUI de chaque élément (delegate, viewDidLoad, reloadData, tapRefresh).
Question d'entretien
« Tu viens d'UIKit. Explique comment tu raisonnes en SwiftUI. »
En UIKit je manipulais des objets vivants de façon impérative : je gardais des références à mes vues et je les mutais (
label.text = ...,reloadData()). En SwiftUI je raisonne enView = f(state): laViewest une struct jetable, recréée à chaque changement, et je ne touche jamais une vue directement — je modifie un état observable (@State,@Observable,@Binding) et le framework recompose l'UI. Donc je ne cherche pas unreload(): je cherche quel morceau d'état n'est pas à jour.
« Pourquoi [weak self] n'est-il pas systématique en SwiftUI alors qu'il l'était en UIKit ? »
Parce qu'une
ViewSwiftUI est une struct (type valeur), pas une classe : ARC ne la retient pas, donc capturerselfdans le closure d'unButtonne crée aucun retain cycle.[weak self]redevient nécessaire dès qu'une classe (un store@Observable) retient une closure long-lived : timer, abonnement Combine,NotificationCenter, callback stocké.
« Que se passe-t-il si je lance un appel réseau dans .task et que l'utilisateur quitte l'écran ? »
La
Taskcréée par.taskest liée au cycle de vie de la vue : au démontage, SwiftUI l'annule automatiquement (annulation coopérative, lesawaitlèventCancellationError). C'est l'équivalent de l'annulation manuelle que je faisais dansviewWillDisappear/deiniten UIKit, mais gratuite.
Résumé
- Modèle mental clé :
View = f(state)— déclaratif, pas impératif. - La
Viewest une struct jetable recréée en boucle ; l'état vit dans@State/@Observable, géré par SwiftUI hors de la struct. - Cycle de vie :
viewDidLoad/viewWillAppear→.task/.onAppear;.tasks'annule tout seul au démontage. - Communication : delegate → closure ; KVO/
NotificationCenter→@Observable; champ lié →@Binding/$. - Layout : Auto Layout + storyboard →
VStack/HStack/ZStack+#Preview; le code est la source de vérité. - Navigation :
push→NavigationStack(path:);present→.sheet/.fullScreenCover;UITabBarController→TabView;dismissvia@Environment(\.dismiss). - Impératif → déclaratif : oublie
reloadData()/setNeeds..., modifie l'état. [weak self]: inutile dans lebodyd'une struct, toujours pertinent dans une classe à closure long-lived.