SwiftUI & SwiftData
Module 11 — Laboratoire pratique · Journal de visites
#Predicate.Voici les fichiers que vous allez créer :
01 Créer le projet
- Lancez Xcode et cliquez sur Create a new Xcode project.
- Sélectionnez iOS puis App.
- Nommez le projet SwiftDataDemo.
- Dans Storage, laissez sur None — on configure tout manuellement.
- Cliquez sur Finish.
@Model.02 Les modèles de données
L'application gère deux entités :
Visitor.swift
- Appuyez sur Cmd+N, choisissez Swift File.
- Nommez-le Visitor.swift et cliquez sur Create.
- Remplacez le contenu par le code suivant :
import Foundation import SwiftData @Model final class Visitor { var firstname: String var lastname: String init(firstname: String, lastname: String) { self.firstname = firstname self.lastname = lastname } }
.xcdatamodeld de Core Data.Le mot-clé
final interdit l'héritage : aucune autre classe ne peut étendre Visitor. Cela permet au compilateur d'optimiser les appels de méthodes (dispatch statique plutôt que dynamique) et rend le code plus prévisible. En pratique, la grande majorité des modèles SwiftData n'ont pas besoin d'être hérités — final est donc presque toujours la bonne option.
LogEntry.swift
- Créez un second fichier Swift nommé LogEntry.swift.
- Ajoutez le code suivant :
import Foundation import SwiftData @Model final class LogEntry { var date: Date init(date: Date) { self.date = date } }
03 Configurer le ModelContainer
Le ModelContainer initialise la base de données SwiftData. On le déclare
une seule fois dans le point d'entrée de l'application avec le modificateur
.modelContainer(for:).
import SwiftUI import SwiftData @main struct SwiftDataDemoApp: App { var body: some Scene { WindowGroup { ContentView() } // Une seule ligne configure toute la persistance .modelContainer(for: Visitor.self) } }
Visitor.self.
SwiftData détecte automatiquement LogEntry via la relation @Relationship
que vous définirez plus loin — pas besoin de le lister explicitement.04 VisitorListView — Liste des visiteurs
Cette vue affiche la liste des visiteurs. Elle utilise @Query pour lire les données
en temps réel et @Environment(\.modelContext) pour les suppressions.
- Créez un nouveau fichier SwiftUI View : Cmd+N → SwiftUI View.
- Nommez-le VisitorListView.swift.
- Remplacez le contenu par le code suivant :
import SwiftUI import SwiftData struct VisitorListView: View { @Environment(\.modelContext) private var context @Query var visitors: [Visitor] var body: some View { List { ForEach(visitors) { visitor in NavigationLink(value: visitor) { if visitor.lastname.isEmpty { Text("Modifier le visiteur") .foregroundStyle(.secondary) } else { Text("\(visitor.lastname), \(visitor.firstname)") } } } .onDelete(perform: deleteVisitor) } } } extension VisitorListView { private func deleteVisitor(_ offsets: IndexSet) { for index in offsets { context.delete(visitors[index]) } } }
Visitor depuis la base. La vue se redessine automatiquement à chaque ajout ou suppression.ContentView via .navigationDestination.context.save().context.save() manuellement. C'est différent de Core Data
où viewContext.save() était obligatoire.05 Ajouter la relation @Relationship
Chaque visiteur peut avoir plusieurs entrées de log. On établit cette relation one-to-many
dans le modèle Visitor avec @Relationship.
Modifiez Visitor.swift en ajoutant les lignes surlignées :
import Foundation import SwiftData @Model final class Visitor { var firstname: String var lastname: String // cascade : supprimer un Visitor supprime aussi toutes ses LogEntry @Relationship(deleteRule: .cascade) var visits: [LogEntry] = [] init(firstname: String, lastname: String) { self.firstname = firstname self.lastname = lastname } }
.cascade assure que supprimer un visiteur supprime automatiquement toutes ses visites — aucune donnée orpheline.LogEntry -> Visitor) automatiquement. Pas besoin de la déclarer dans
LogEntry, contrairement à Core Data.06 VisitorDetailView — Détail et ajout de visites
Cette vue permet de modifier le prénom et le nom d'un visiteur, et d'ajouter des entrées dans
son journal. Elle utilise @Bindable pour lier les champs de texte directement aux
propriétés du modèle — sans @State intermédiaire.
- Créez un nouveau fichier SwiftUI View nommé VisitorDetailView.swift.
- Ajoutez le code suivant :
import SwiftUI import SwiftData struct VisitorDetailView: View { // @Bindable permet $visitor.firstname et $visitor.lastname // directement dans les TextField — sans copie @State @Bindable var visitor: Visitor var body: some View { Form { Section("Visiteur") { TextField("Prénom", text: $visitor.firstname) TextField("Nom", text: $visitor.lastname) } Section("Historique des visites") { Button("Ajouter une visite", action: addVisit) ForEach(visitor.visits) { visit in Text(visit.date.formatted( date: .abbreviated, time: .shortened )) } } } .navigationTitle("Détail du visiteur") .navigationBarTitleDisplayMode(.inline) } } extension VisitorDetailView { private func addVisit() { visitor.visits.append(LogEntry(date: Date.now)) } }
$visitor.firstname directement sur un objet @Model. SwiftData détecte la modification et la persiste automatiquement. Beaucoup plus simple que le pattern @State + saveChanges() de Core Data.LogEntry au tableau visitor.visits. SwiftData détecte la mutation du tableau et persiste la nouvelle entrée via l'autosave..abbreviated produit "9 oct. 2024" et .shortened produit "14:32". Formate la date de façon lisible et localisée.@State private var name comme copie locale, puis on la réécrivait dans l'objet
avec saveChanges(). Avec @Bindable, on lie directement les propriétés
du modèle — le code est plus court et les risques d'oubli de sauvegarde sont éliminés.07 ContentView — Navigation principale
Le ContentView intègre VisitorListView dans un NavigationStack,
définit la destination de navigation et ajoute un bouton pour créer un nouveau visiteur.
import SwiftUI import SwiftData struct ContentView: View { @Environment(\.modelContext) private var context var body: some View { NavigationStack { VisitorListView() .navigationTitle("Visiteurs") // Quand un NavigationLink(value: visitor) est activé, // SwiftUI crée automatiquement un VisitorDetailView(visitor:) .navigationDestination(for: Visitor.self, destination: VisitorDetailView.init) .toolbar { Button("Nouveau visiteur", systemImage: "plus", action: addVisitor) } } } } extension ContentView { private func addVisitor() { context.insert(Visitor(firstname: "", lastname: "")) } } #Preview { ContentView() }
NavigationLink(value: visitor) dans l'arbre affiche VisitorDetailView(visitor:) en passant l'objet automatiquement. Pattern iOS 16+ recommandé.@Query.08 Recherche avec #Predicate
On rend la liste filtrable avec une barre de recherche dans ContentView
et un #Predicate dynamique dans VisitorListView.
@Query ne peut pas capturer directement
une variable @State de la vue parente. La solution est de passer la valeur via
init() et d'initialiser @Query à l'intérieur.Étape 1 — Ajouter la barre de recherche dans ContentView
struct ContentView: View { @Environment(\.modelContext) private var context @State private var search: String = "" var body: some View { NavigationStack { VisitorListView(search: search) .navigationTitle("Visiteurs") .searchable(text: $search) .navigationDestination(for: Visitor.self, destination: VisitorDetailView.init) .toolbar { Button("Nouveau visiteur", systemImage: "plus", action: addVisitor) } } } } extension ContentView { private func addVisitor() { context.insert(Visitor(firstname: "", lastname: "")) } } #Preview { ContentView() }
Étape 2 — Ajouter le filtre dans VisitorListView
On ajoute un init(search:) qui reconfigure @Query
avec un #Predicate dynamique selon le texte saisi.
struct VisitorListView: View { @Environment(\.modelContext) private var context @Query var visitors: [Visitor] var body: some View { List { ForEach(visitors) { visitor in NavigationLink(value: visitor) { if visitor.lastname.isEmpty { Text("Modifier le visiteur").foregroundStyle(.secondary) } else { Text("\(visitor.lastname), \(visitor.firstname)") } } } .onDelete(perform: deleteVisitor) } } // Initializer qui configure @Query selon le texte de recherche init(search: String) { let predicate = #Predicate<Visitor> { visitor in if search.isEmpty { return true // Champ vide = afficher tous les visiteurs } else { // Filtrer par nom, insensible à la casse et aux accents return visitor.lastname.localizedStandardContains(search) } } // _visitors = accès à la valeur sous-jacente de @Query depuis l'init _visitors = Query(filter: predicate, sort: \.lastname) } } extension VisitorListView { private func deleteVisitor(_ offsets: IndexSet) { for index in offsets { context.delete(visitors[index]) } } }
_ accède à la valeur sous-jacente du property wrapper @Query depuis l'init. C'est le seul endroit où cette syntaxe est utilisée.