Views, modifiers, state management, NavigationStack, animations — la nouvelle norme UI Apple.
.animation, withAnimation, transitions et matchedGeometryEffect.UIViewRepresentable.Annoncé à la WWDC 2019 (iOS 13+), SwiftUI est le framework UI moderne d'Apple, inspiré de React, Flutter et Jetpack Compose. Là où UIKit (impératif, MVC, depuis 2007) demande de manipuler chaque vue et son cycle de vie manuellement, SwiftUI décrit l'état souhaité de l'UI à un instant T : le framework s'occupe du diffing et redessine uniquement ce qui change.
Une View SwiftUI est une struct (value type immuable) conforme au protocole View, exposant une propriété calculée body. Le compilateur Swift utilise un result builder (@ViewBuilder) pour assembler les sous-vues en arbre statique typé, vérifié à la compilation.
struct GreetingView: View {
var name: String
var body: some View {
VStack(spacing: 12) {
Text("Bonjour, \(name)")
.font(.largeTitle)
.foregroundStyle(.blue)
Image(systemName: "hand.wave.fill")
.imageScale(.large)
}
.padding()
}
}
Les modifiers (.font, .padding, .background...) retournent une nouvelle View. L'ordre compte : .padding().background(Color.red) n'est pas équivalent à .background(Color.red).padding().
"SwiftUI is a modern way to declare user interfaces for any Apple platform. Create beautiful, dynamic apps faster than ever before." — Apple Developer — SwiftUI
SwiftUI redessine une View lorsque sa source de vérité change. Cette source est encapsulée dans des property wrappers spécifiques selon la portée et la propriété :
| Wrapper | Usage | Portée |
|---|---|---|
@State | État local privé de la View (struct) | Une seule View, valeur initialisée à la construction |
@Binding | Référence two-way à un @State parent | Sous-View qui modifie l'état du parent |
@StateObject | Possession d'un ObservableObject (créé par cette View) | ViewModel instancié et détenu par cette View |
@ObservedObject | Référence à un ObservableObject existant (créé ailleurs) | ViewModel reçu en paramètre |
@EnvironmentObject | Injection global (DI) via l'environnement | Singleton-like, traverse plusieurs niveaux de navigation |
@Environment(\.dismiss) | Valeurs système (colorScheme, dismiss, locale...) | Toute View enfant |
iOS 17 introduit la macro @Observable (framework Observation) qui remplace ObservableObject + @Published par un mécanisme plus performant basé sur le tracking automatique. Désormais :
@Observable class UserViewModel {
var user: User?
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
user = try? await API.fetchCurrentUser()
}
}
struct ProfileView: View {
@State private var vm = UserViewModel() // plus de @StateObject !
var body: some View {
Group {
if vm.isLoading { ProgressView() }
else if let user = vm.user { Text(user.name) }
}
.task { await vm.load() }
}
}
Depuis iOS 16, NavigationStack remplace le NavigationView déprécié. Il gère une pile de destinations typées via NavigationPath, ce qui permet la deep linking et la restauration d'état.
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(0..<10) { i in
NavigationLink("Item \(i)", value: i)
}
.navigationTitle("Accueil")
.navigationDestination(for: Int.self) { value in
DetailView(index: value)
}
}
}
}
Pour les écrans modaux, on utilise .sheet(isPresented:) (modal carte iOS 13+), .fullScreenCover (plein écran sans drag dismiss), .alert et .confirmationDialog pour les confirmations natives système.
Animer en SwiftUI consiste à modifier un état dans un bloc withAnimation { }. Le framework interpole automatiquement les valeurs animables (opacity, scale, offset, color, cornerRadius...) entre les deux états.
struct LikeButton: View {
@State private var liked = false
var body: some View {
Image(systemName: liked ? "heart.fill" : "heart")
.foregroundStyle(liked ? .red : .gray)
.scaleEffect(liked ? 1.3 : 1.0)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: liked)
.onTapGesture { liked.toggle() }
}
}
Pour les transitions plus avancées (effet "hero" entre deux écrans), matchedGeometryEffect identifie une vue par un id et un namespace partagé : SwiftUI interpole position, taille et forme automatiquement entre les états.
| Critère | UIKit (depuis 2007) | SwiftUI (depuis 2019) |
|---|---|---|
| Paradigme | Impératif (MVC) | Déclaratif (state-driven) |
| Cycle de vie | viewDidLoad, viewWillAppear... | .onAppear, .task |
| Layout | Auto Layout + constraints | VStack/HStack/ZStack + frame |
| Cible minimum | iOS 2.0 | iOS 13 (mais iOS 16+ pour confort) |
| Live Preview | Non | Oui (Canvas Xcode) |
| Camera, AR, MapKit | Native | Wrap via UIViewRepresentable |
struct Task: Identifiable {
let id = UUID()
var title: String
var done: Bool = false
}
struct TaskListView: View {
@State private var tasks: [Task] = [
Task(title: "Lire la doc SwiftUI"),
Task(title: "Configurer Xcode 15"),
Task(title: "Publier sur TestFlight")
]
@State private var newTitle: String = ""
var body: some View {
NavigationStack {
VStack {
HStack {
TextField("Nouvelle tâche", text: $newTitle)
.textFieldStyle(.roundedBorder)
Button("Ajouter") {
guard !newTitle.isEmpty else { return }
withAnimation {
tasks.append(Task(title: newTitle))
newTitle = ""
}
}
}.padding()
List($tasks) { $task in
HStack {
Image(systemName: task.done ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.done ? .green : .gray)
.onTapGesture {
withAnimation { task.done.toggle() }
}
Text(task.title)
.strikethrough(task.done)
}
}
}
.navigationTitle("Mes tâches")
}
}
}#Preview (macro Swift 5.9+) avec plusieurs configurations pour tester light/dark mode et différentes tailles d'écran sans recompiler.@ObservedObject à la place de @StateObject dans la View qui crée le ViewModel provoque une recréation à chaque redraw — perte d'état garantie. Règle : @StateObject dans la View propriétaire, @ObservedObject dans les enfants.Le passage de @ObservableObject au macro @Observable (iOS 17+) est l'évolution la plus importante de SwiftUI depuis sa naissance. Sous le capot, @Observable applique du tracking par observation granulaire au niveau propriété : SwiftUI ne réinvalide une vue que si la propriété spécifique lue dans body change. Avec l'ancien @Published, toute modification de n'importe quelle propriété déclenchait une réinvalidation de toutes les vues observant l'objet. Le gain de performances est mesurable : sur des écrans complexes (200+ vues), on observe 30 à 60 % de moins de recalculs de body.
La règle d'or : @State pour la propriété d'une vue, @Bindable pour passer un objet observable mutable à une sous-vue, @Environment(\.modelContext) ou similaire pour les services injectés via l'environnement. Le triplet @StateObject/@ObservedObject/@EnvironmentObject reste valide pour la compatibilité descendante mais devient legacy. Une migration partielle est possible : vous pouvez avoir des @Observable et des ObservableObject cohabitant dans la même app.
Tout modifier en SwiftUI est en réalité une fonction qui prend une View et retourne une autre View. Pour créer le vôtre, on conforme un type à ViewModifier et on implémente body(content:). L'astuce est d'envelopper l'usage dans une extension View pour bénéficier d'une API fluide. Le bénéfice n'est pas qu'esthétique : un modifier custom est cacheable par le compilateur car il a une identité de type stable, ce qui permet à SwiftUI de mémoriser la sortie. Cela contraste avec un wrapper @ViewBuilder ad-hoc qui doit être reconstruit.
Le @ViewBuilder lui-même mérite attention : c'est un result builder qui transforme une séquence de View en TupleView. Sa limitation officielle est 10 vues (mais on peut combiner avec Group pour contourner). Plus subtil : un if/else dans un @ViewBuilder produit un _ConditionalContent, ce qui change l'identité de la vue selon la branche. SwiftUI détruit puis recrée l'état (@State) à chaque bascule, ce qui peut causer des regressions visuelles. Pour préserver l'état, utiliser un modifier conditionnel : view.opacity(condition ? 1 : 0) au lieu de if condition { view }.
Le protocole Transferable (iOS 16+) unifie tout transfert de données : drag & drop, copy-paste, partage ShareLink. On implémente static var transferRepresentation: some TransferRepresentation avec un DataRepresentation, FileRepresentation, ou CodableRepresentation. Le runtime sélectionne le format optimal selon le destinataire : si vous draggez un type vers Finder, c'est le FileRepresentation qui est utilisé ; vers une autre app SwiftUI, c'est la version Codable typée.
Combiner .draggable et .dropDestination permet de construire une UI de réordonnancement quasi gratuite. Sur iPad multitâche, .draggable permet le drag inter-app sans code supplémentaire. Le piège : sur iOS, contrairement à macOS, un drag long-press d'un seul élément n'est pas le geste natif. Il faut typiquement initier le drag depuis une zone tactile dédiée ou utiliser EditMode.
Scénario : une todo-list où l'utilisateur réordonne les items par drag. Le challenge est de typer le payload (TodoItem) tout en supportant l'export vers d'autres apps comme du texte brut. Transferable avec multiple représentations résout cela élégamment.
struct TodoItem: Codable, Identifiable, Transferable {
let id: UUID
var title: String
var done: Bool
static var transferRepresentation: some TransferRepresentation {
// Format natif (drag intra-app, typé)
CodableRepresentation(contentType: .todoItem)
// Format texte (export Notes, Mail, etc.)
ProxyRepresentation(exporting: \.title)
}
}
extension UTType {
static let todoItem = UTType(exportedAs: "com.itag.lms.todoItem")
}
struct TodoList: View {
@State private var items: [TodoItem] = TodoItem.sample
var body: some View {
List {
ForEach(items) { item in
Text(item.title)
.draggable(item)
}
.onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
}
.dropDestination(for: TodoItem.self) { incoming, _ in
items.append(contentsOf: incoming); return true
}
}
}
Scénario : signaler une erreur de validation sur un champ de saisie avec un effet de secousse horizontale (UX classique iOS). Au lieu de réimplémenter à chaque écran, on en fait un ViewModifier réutilisable, drivé par un boolean.
struct ShakeEffect: GeometryEffect {
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
let amount: CGFloat = 8
let dx = amount * sin(animatableData * .pi * 4)
return ProjectionTransform(CGAffineTransform(translationX: dx, y: 0))
}
}
extension View {
func shake(_ trigger: Bool) -> some View {
modifier(ShakeEffect(animatableData: trigger ? 1 : 0))
.animation(.linear(duration: 0.4), value: trigger)
}
}
// Usage
TextField("Email", text: $email)
.shake(emailInvalid)
| Approche | Performance | Lisibilité | Cas d'usage |
|---|---|---|---|
@Observable macro (iOS 17+) | Excellente (tracking granulaire) | Excellente | Tout nouveau code iOS 17+ |
@ObservableObject + @Published | Moyenne (réinvalide tout) | Bonne | Compatibilité iOS <17 |
@State primitif | Maximale | Excellente | État local d'une vue, value type |
UIViewRepresentable | Variable (bridge cost) | Moyenne | Intégrer UIKit dans SwiftUI |
Canvas + TimelineView | Excellente (drawing direct) | Bonne | Animations 60-120 fps custom |
List | Bonne (lazy) | Excellente | Listes standards iOS |
LazyVStack in ScrollView | Très bonne mais layout custom | Bonne | Listes avec headers sticky, mix de types |
NavigationStack (iOS 16+) | Excellente (path typé) | Excellente | Navigation moderne, deep links |
ForEach — Si l'id change entre deux renders, SwiftUI détruit l'instance et recrée tous les @State. Toujours conformer à Identifiable avec un id stable (UUID, primary key DB). N'utilisez jamais id: \.self sur des structs mutables..environment(\.foo) haut dans la hiérarchie réinvalide tout le sous-arbre. Limiter la portée en injectant l'environnement le plus près possible des consommateurs..id(value) sur une List change son identité et fait remonter le scroll en haut à chaque changement de value. Bug classique de "ma liste remonte toute seule".GeometryReader consomme tout l'espace proposé par défaut, brisant les layouts compacts. Préférer le modifier .background(GeometryReader { ... }) pour mesurer sans perturber..animation(.default, value: x) anime même la valeur initiale si x change avant l'apparition. Utiliser .transaction { $0.animation = nil } sur le premier render pour figer.Self._printChanges() + .dynamicTypeSize(.accessibility5) dans le preview. Préférer ScrollView + VStack à HStack pour les formes longues..accessibilityLabel("Fermer") sur les boutons-icônes. .accessibilityHidden(true) pour les éléments décoratifs..transition() mais avec des effets très polishés. movingparts.io/pow"The Observation framework provides a Swift-specific implementation of the observer design pattern. Use the @Observable() macro to track changes to your data without the overhead of @Published." — developer.apple.com/documentation/observation
"SwiftUI applies accessibility modifiers to your views by inferring them from the views themselves. You can override or supplement these inferred properties to make your app more accessible." — developer.apple.com/documentation/swiftui/accessibility-fundamentals
| Terme | Définition |
|---|---|
| @Observable | Macro Swift 5.9 (iOS 17+) qui rend un type observable avec tracking granulaire par propriété. Remplace @ObservableObject+@Published. |
| @Bindable | Wrapper qui permet de créer un Binding vers une propriété d'un objet @Observable non possédé par la vue. |
| ViewModifier | Protocole pour créer des modifiers réutilisables. Méthode body(content: Content) -> some View. |
| ViewBuilder | Result builder qui assemble des View en TupleView. Permet la syntaxe déclarative imbriquée. |
| EnvironmentValues | Dictionnaire typé propagé dans l'arbre des vues. Étendu via extension EnvironmentValues + @Entry. |
| Identity | Concept clé SwiftUI : SwiftUI compare les identités entre renders pour décider de réutiliser ou recréer les vues. Stable = pas de recréation. |
| Transferable | Protocole iOS 16+ unifiant transfert de données (drag&drop, paste, share). Remplace NSItemProvider. |
| NavigationStack | Container de navigation moderne (iOS 16+) supportant un path typé pour deep linking et state restoration. |
| Dynamic Type | Système iOS d'ajustement automatique de la taille de texte selon préférences utilisateur (5 tailles standard + 7 accessibilité). |
| VoiceOver | Lecteur d'écran iOS pour utilisateurs malvoyants. SwiftUI infère beaucoup d'annotations ; le développeur complète avec .accessibility*. |
Inscrivez-vous pour accéder aux 5 autres leçons + le quiz final.
Créer mon compteChoisis quels cookies tu acceptes — modifiable à tout moment.