SCours SwiftUI
Fiche 00.08

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 :

Swift
// UIKit — impératif : tu donnes des ordres à des objets label.text = "Bonjour" button.isHidden = true tableView.reloadData()

En SwiftUI, tu décris ce que l'écran doit être pour un état donné, et le framework recalcule l'UI :

Swift
// SwiftUI — déclaratif : tu décris, le framework dessine struct GreetingView: View { @State private var name = "Bonjour" @State private var showButton = false var body: some View { VStack { Text(name) if showButton { Button("OK") {} } } } }

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)

UIKitSwiftUI
UIView / UIViewController = classe (référence), instance vivante que tu gardes et mutesView = struct (valeur), recréée à chaque recalcul
Tu gardes un self pendant tout le cycle de vieLa struct est créée, lue (body), jetée. En permanence.
Stocker une référence est normalStocker 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.

Swift
struct CounterView: View { @State private var count = 0 // SwiftUI garde ça vivant, PAS la struct var body: some View { // ce bloc est ré-exécuté à chaque changement de count Button("Count: \(count)") { count += 1 } } }

Question piège : « où est stocké count si la struct est recréée ? » → Dans un storage géré par SwiftUI, associé à l'emplacement de la vue dans l'arbre. @State n'est qu'un pointeur vers ce storage.


3. Table — cycle de vie

UIKitSwiftUINotes
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.

Swift
struct ProfileView: View { @State private var profile: Profile? var body: some View { content .task { // annulée auto si la vue est démontée avant la fin profile = try? await api.loadProfile() } } }

.task(id:) : relance l'action quand id change (é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.

BesoinUIKitSwiftUI / Swift moderne
Enfant → parent (callback ponctuel)delegate (protocole + weak var delegate)closure passée en paramètre
Partager un état observableKVO, didSet, NotificationCenter@Observable (macro Observation)
Lier un champ ↔ une donnéetarget-action + lire .text@Binding / $value (two-way)
Diffuser un événement globalNotificationCenter@Observable injecté en @Environment, ou parfois encore NotificationCenter
Réagir à un boutonaddTarget(_:action:)closure dans Button("...") { }

ViewModel moderne (Observation, pas ObservableObject/@Published) :

Swift
@Observable final class CounterStore { var count = 0 func increment() { count += 1 } } struct CounterScreen: View { @State private var store = CounterStore() // @State possède l'objet var body: some View { Button("Count: \(store.count)") { store.increment() } } }

Enfant → parent par closure (l'équivalent direct de ton vieux delegate) :

Swift
struct RatingPicker: View { let onSelect: (Int) -> Void // ← ton "delegate", en une ligne var body: some View { HStack { ForEach(1...5, id: \.self) { value in Button("\(value)") { onSelect(value) } } } } } // parent RatingPicker { rating in print("choisi: \(rating)") }

Two-way binding ($) — l'équivalent du target-action qui lit/écrit un champ :

Swift
struct NameField: View { @Binding var name: String // lecture ET écriture chez le parent var body: some View { TextField("Nom", text: $name) } }

Erreur fréquente d'un dev UIKit : recréer un protocol XDelegate pour chaque interaction. En SwiftUI, une closure suffit dans 90 % des cas.


5. Table — layout

UIKitSwiftUI
Auto Layout (NSLayoutConstraint, anchors)VStack / HStack / ZStack + modifiers (.padding, .frame, .layoutPriority)
IBOutlet / IBAction(disparus) — données via @State/bindings, actions via closures
Storyboard / XIBLe code est la source de vérité
Voir le rendu = build + run sur simulateur#Preview (rendu live, plusieurs états côte à côte)
addSubview + contraintesimbrication déclarative dans body
UIStackViewVStack / HStack (mêmes idées, sans config impérative)
safeAreaLayoutGuidegéré par défaut, .ignoresSafeArea() pour sortir
Swift
// L'équivalent d'un UIStackView vertical + 2 sous-vues, sans contraintes VStack(alignment: .leading, spacing: 12) { Text("Titre").font(.headline) Text("Sous-titre").foregroundStyle(.secondary) } .padding()

Le #Preview remplace l'aller-retour build/run et te laisse afficher plusieurs configurations d'un coup :

Swift
#Preview { VStack { RatingPicker { _ in } NameField(name: .constant("Guillaume")) } }

.constant("...") : un binding bidon pour les previews, quand tu n'as pas de @State parent sous la main.


6. Table — navigation

UIKitSwiftUI
navigationController?.pushViewController(vc, animated: true)NavigationStack { NavigationLink(...) }
Push programmatiqueNavigationStack(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()
UITabBarControllerTabView { ... .tabItem { } }
segues (storyboard)(disparus) — tout est explicite en code

Navigation programmatique (l'équivalent moderne d'un UINavigationController que tu pilotes) :

Swift
enum Route: Hashable { case detail(id: Int), settings } struct RootView: View { @State private var path: [Route] = [] var body: some View { NavigationStack(path: $path) { List { Button("Voir détail 42") { path.append(.detail(id: 42)) } } .navigationDestination(for: Route.self) { route in switch route { case .detail(let id): Text("Détail \(id)") case .settings: Text("Réglages") } } } } }

Tu manipules un tableau d'état (path) au lieu d'appeler push/pop. path.removeLast() = pop ; path = [] = pop to root.

Swift
// Fermer une sheet depuis l'écran présenté struct EditView: View { @Environment(\.dismiss) private var dismiss var body: some View { Button("Fermer") { dismiss() } } }

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 = trueif condition { ... } dans body
setNeedsDisplay()rien — recompose sur changement d'état
Swift
// UIKit : ajouter un item PUIS recharger items.append(newItem) tableView.reloadData() // SwiftUI : ajouter à l'état suffit. La List dérive de items. items.append(newItem) // la List se reconstruit toute seule

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.

Swift
struct TodoView: View { @State private var store = TodoStore() var body: some View { // PAS de [weak self] : self est une struct, aucun cycle possible Button("Ajouter") { store.add() } } }

Mais [weak self] reste utile et nécessaire dès qu'une classe est dans la boucle :

Swift
@Observable final class FeedStore { // ← classe (référence) var items: [Item] = [] private var timer: Timer? func startPolling() { // ici [weak self] est PERTINENT : closure long-lived retenue par le timer timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in Task { await self?.refresh() } } } func refresh() async { /* ... */ } }

Règle de tri :

  • closure dans le body d'une View (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 body ni dans l'init d'une View : body tourne en boucle. Le travail va dans .task/.onAppear ou 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 le body d'une struct, toujours pertinent dans une classe à closure long-lived.
  • .task s'annule tout seul au démontage : ne réimplémente pas la gestion d'annulation que tu faisais dans viewWillDisappear/deinit.

Exercice

Voici un UIViewController en pseudo-UIKit. Traduis-le en View SwiftUI et liste, pour chaque ligne, son équivalent.

Texte
class UserVC: UIViewController, UserServiceDelegate { weak var delegate: UserServiceDelegate? var users: [String] = [] override func viewDidLoad() { super.viewDidLoad(); service.loadUsers() } // async func didLoad(users: [String]) { self.users = users; tableView.reloadData() } @objc func tapRefresh() { service.loadUsers() } }

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 en View = f(state) : la View est 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 un reload() : 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 View SwiftUI est une struct (type valeur), pas une classe : ARC ne la retient pas, donc capturer self dans le closure d'un Button ne 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 Task créée par .task est liée au cycle de vie de la vue : au démontage, SwiftUI l'annule automatiquement (annulation coopérative, les await lèvent CancellationError). C'est l'équivalent de l'annulation manuelle que je faisais dans viewWillDisappear/deinit en UIKit, mais gratuite.


Résumé

  • Modèle mental clé : View = f(state) — déclaratif, pas impératif.
  • La View est 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 ; .task s'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 : pushNavigationStack(path:) ; present.sheet/.fullScreenCover ; UITabBarControllerTabView ; dismiss via @Environment(\.dismiss).
  • Impératif → déclaratif : oublie reloadData()/setNeeds..., modifie l'état.
  • [weak self] : inutile dans le body d'une struct, toujours pertinent dans une classe à closure long-lived.