← Accueil IFM025921 · Module 11 · Laboratoire SwiftData iOS 17+

SwiftUI & SwiftData

Module 11 — Laboratoire pratique · Journal de visites

*
Objectif : Construire une application de journal de visites — un registre de visiteurs avec leur historique. L'exercice couvre les modèles SwiftData, les relations entre entités, la navigation SwiftUI et la recherche avec #Predicate.

Voici les fichiers que vous allez créer :

SwiftDataDemo/
SwiftDataDemoApp.swift
ContentView.swift
Visitor.swift
LogEntry.swift
VisitorListView.swift
VisitorDetailView.swift

01 Créer le projet

  1. Lancez Xcode et cliquez sur Create a new Xcode project.
  2. Sélectionnez iOS puis App.
  3. Nommez le projet SwiftDataDemo.
  4. Dans Storage, laissez sur None — on configure tout manuellement.
  5. Cliquez sur Finish.
i
Pas de fichier .xcdatamodeld : Contrairement à Core Data, SwiftData ne nécessite aucun fichier de modèle visuel. Tout se définit directement dans le code Swift avec la macro @Model.

02 Les modèles de données

L'application gère deux entités :

Visitor
Représente un visiteur — prénom et nom de famille.
LogEntry
Représente une visite — la date et l'heure. Un visiteur peut avoir plusieurs entrées de log (relation one-to-many).

Visitor.swift

  1. Appuyez sur Cmd+N, choisissez Swift File.
  2. Nommez-le Visitor.swift et cliquez sur Create.
  3. Remplacez le contenu par le code suivant :
Visitor.swift
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
    }
}
@Model
Transforme la classe en modèle SwiftData persistant. Remplace entièrement le fichier .xcdatamodeld de Core Data.
final class
SwiftData requiert des classes (pas des structs) car les objets doivent être suivis par référence — SwiftData a besoin de détecter les mutations sur le même objet partagé en mémoire.

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

  1. Créez un second fichier Swift nommé LogEntry.swift.
  2. Ajoutez le code suivant :
LogEntry.swift
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:).

SwiftDataDemoApp.swift
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)
    }
}
+
Un seul modèle suffit : On passe uniquement 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.

  1. Créez un nouveau fichier SwiftUI View : Cmd+N → SwiftUI View.
  2. Nommez-le VisitorListView.swift.
  3. Remplacez le contenu par le code suivant :
VisitorListView.swift
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])
        }
    }
}
@Query var visitors
Lit tous les objets Visitor depuis la base. La vue se redessine automatiquement à chaque ajout ou suppression.
NavigationLink(value: visitor)
Crée un lien de navigation en passant l'objet. La vue de destination est définie dans ContentView via .navigationDestination.
deleteVisitor(_:)
Supprime les visiteurs du contexte. L'autosave de SwiftData persiste les changements automatiquement — pas besoin d'appeler context.save().
!
Autosave : SwiftData active l'autosave par défaut — les suppressions sont persistées sans appeler 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 :

Visitor.swift — mis à jour
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
    }
}
@Relationship(deleteRule: .cascade)
Définit la relation one-to-many. La règle .cascade assure que supprimer un visiteur supprime automatiquement toutes ses visites — aucune donnée orpheline.
var visits: [LogEntry] = []
Tableau initialisé vide. Un nouveau visiteur n'a aucune visite au départ.
i
Relation inverse automatique : SwiftData déduit la relation inverse (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.

  1. Créez un nouveau fichier SwiftUI View nommé VisitorDetailView.swift.
  2. Ajoutez le code suivant :
VisitorDetailView.swift
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))
    }
}
@Bindable var visitor
Permet la liaison bidirectionnelle $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.
addVisit()
Ajoute une LogEntry au tableau visitor.visits. SwiftData détecte la mutation du tableau et persiste la nouvelle entrée via l'autosave.
.formatted(date:time:)
.abbreviated produit "9 oct. 2024" et .shortened produit "14:32". Formate la date de façon lisible et localisée.
+
@Bindable vs Core Data : Dans l'exercice Core Data, on utilisait @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.

ContentView.swift
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()
}
.navigationDestination(for: Visitor.self, destination: VisitorDetailView.init)
Déclare que tout NavigationLink(value: visitor) dans l'arbre affiche VisitorDetailView(visitor:) en passant l'objet automatiquement. Pattern iOS 16+ recommandé.
addVisitor()
Crée un visiteur vide et l'insère dans le contexte. L'entrée "Modifier le visiteur" apparaît immédiatement dans la liste grâce à @Query.
i
Tester l'app : Lancez le simulateur. Tapez sur + — un visiteur vide apparaît. Sélectionnez-le, entrez un prénom et un nom, puis tapez Ajouter une visite. Fermez et relancez l'app — les données sont persistées.

08 Recherche avec #Predicate

On rend la liste filtrable avec une barre de recherche dans ContentView et un #Predicate dynamique dans VisitorListView.

!
Limitation de @Query : @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

ContentView.swift — mis à jour
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.

VisitorListView.swift — mis à jour
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]) }
    }
}
#Predicate<Visitor> { ... }
Définit le critère de filtrage en Swift pur, vérifié à la compilation. Si le nom d'une propriété change, Xcode le signale immédiatement — impossible d'avoir un crash en production à cause d'une faute dans une string.
.localizedStandardContains()
Recherche insensible à la casse et aux accents. "smith" trouve "Smith", "SMITH", "Smíth", etc. Méthode recommandée pour les recherches texte utilisateur.
_visitors = Query(filter:sort:)
Le préfixe _ accède à la valeur sous-jacente du property wrapper @Query depuis l'init. C'est le seul endroit où cette syntaxe est utilisée.
sort: \.lastname
Les résultats sont toujours triés alphabétiquement par nom de famille, que la recherche soit active ou non.
+
Tester la recherche : Ajoutez plusieurs visiteurs avec des noms similaires (ex. "Smith, Tom", "Smith, Bob", "Smithers, Alice"). La barre de recherche apparaît en tirant la liste vers le bas. Tapez "smith" — seuls les visiteurs dont le nom contient "smith" s'affichent, insensible à la casse.