SwiftUI & Core Data
Module 10 Laboratoire pratique · Inventaire de produits
01 Créer le projet « CoreDataDemo »
- Lancez Xcode et cliquez sur « Create a new Xcode project ».
- Sélectionnez iOS puis App.
- Nommez le projet CoreDataDemo.
- Dans la partie Storage, laissez sur None (ne cochez pas Core Data).
- Choisissez l'emplacement et cliquez sur Finish.
02 Ajouter le modèle de données
Comme Core Data n'a pas été coché, il n'y a pas de fichier .xcdatamodeld dans le projet.
On va le créer manuellement.
- Appuyez sur ⌘ N pour créer un nouveau fichier.
- Dans la section Core Data, sélectionnez Data Model, puis Next.
- Nommez le fichier Products et cliquez sur Create.
Un fichier Products.xcdatamodeld apparaît dans l'arborescence du projet.
Définir une entité
- Ouvrez
Products.xcdatamodelddans Xcode. - Cliquez sur Add Entity en bas de l'éditeur.
- Double-cliquez sur le nom par défaut et renommez-le Product.
- Dans la section Attributes, cliquez sur + deux fois pour ajouter :
Int16 ou Int32.03 Créer le PersistenceController
Ce fichier configure la pile Core Data (le « stack »). C'est lui qui crée la base de données SQLite et qui fournit le contexte de travail.
- Appuyez sur ⌘ N, choisissez Swift File.
- Nommez-le Persistence.swift.
- Remplacez le contenu par le code suivant :
import CoreData struct PersistenceController { // Instance unique partagée dans toute l'application (singleton) static let shared = PersistenceController() let container: NSPersistentContainer init() { // "Products" = nom du fichier .xcdatamodeld (sans l'extension) container = NSPersistentContainer(name: "Products") container.loadPersistentStores { (storeDescription, error) in if let error = error as NSError? { // En production, on afficherait une alerte plutôt qu'un crash fatalError("Échec du chargement du store: \(error)") } } // Synchronise automatiquement le contexte principal container.viewContext.automaticallyMergesChangesFromParent = true } }
04 Injecter le contexte dans SwiftUI
Pour que toutes les vues puissent accéder à la base de données, on injecte le viewContext dans l'environnement SwiftUI dès le point d'entrée de l'application.
import SwiftUI @main struct CoreDataDemoApp: App { let persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { ContentView() // Toutes les vues enfants pourront accéder au contexte via @Environment .environment(\.managedObjectContext, persistenceController.container.viewContext) } } }
@Environment(\.managedObjectContext) sans qu'on ait à le passer manuellement de vue en vue.05 ContentView Interface principale
import SwiftUI import CoreData struct ContentView: View { // Champs de saisie liés à l'interface @State private var name = "" @State private var quantity = "" // Contexte Core Data injecté depuis l'environnement @Environment(\.managedObjectContext) private var viewContext // Syntaxe moderne (iOS 15+) plus concise et type-safe @FetchRequest(sortDescriptors: []) private var products: FetchedResults<Product> var body: some View { NavigationStack { VStack { TextField("Nom du produit", text: $name) TextField("Quantité", text: $quantity) .keyboardType(.numberPad) HStack { Spacer() Button("Ajouter") { addProduct() } // Désactivé tant que les champs sont vides .disabled(name.isEmpty || quantity.isEmpty) Spacer() Button("Effacer") { name = "" quantity = "" } Spacer() } .padding() List { ForEach(products) { product in HStack { Text(product.name ?? "Inconnu") Spacer() Text(product.quantity ?? "0") } } .onDelete(perform: deleteProducts) } .listStyle(.plain) } .padding() .textFieldStyle(RoundedBorderTextFieldStyle()) .navigationTitle("Inventaire") } } }
Product de la base. La liste se met à jour automatiquement dès qu'un produit est ajouté ou supprimé.06 Fonctions CRUD
On ajoute ces fonctions dans une extension de ContentView pour garder le code bien organisé.
extension ContentView { private func addProduct() { withAnimation { let newProduct = Product(context: viewContext) newProduct.name = name newProduct.quantity = quantity saveContext() // On vide les champs après l'ajout pour éviter les doublons accidentels name = "" quantity = "" } } private func deleteProducts(offsets: IndexSet) { withAnimation { offsets.map { products[$0] }.forEach(viewContext.delete) saveContext() } } private func saveContext() { do { try viewContext.save() } catch { // En production : afficher une alerte à l'utilisateur fatalError("Erreur lors de la sauvegarde : \(error)") } } }
Product, assigne les valeurs, sauvegarde, puis vide les champs..onDelete, puis sauvegarde.viewContext.save() pour persister les changements dans SQLite.fatalError par une alerte SwiftUI qui informe l'utilisateur. fatalError
fait planter l'app c'est acceptable en développement/démo, jamais en production.07 Améliorations possibles
Trier les produits par nom
Pour afficher les produits en ordre alphabétique, on remplace le
@FetchRequest par une version avec tri.
SortDescriptor(\.name) est
vérifié à la compilation si le nom de l'attribut change, Xcode le signale
immédiatement. C'est plus sûr que l'ancien NSSortDescriptor(key: "name", ...)
qui utilise une chaîne de caractères non vérifiée.// SortDescriptor moderne vérifié à la compilation (iOS 15+) @FetchRequest(sortDescriptors: [SortDescriptor(\.name)]) private var products: FetchedResults<Product>
Rechercher un produit
On crée une vue ResultsView qui filtre les produits selon le nom saisi.
Elle récupère son contexte depuis l'environnement pas besoin de le passer en paramètre.
struct ResultsView: View { var name: String // Contexte récupéré depuis l'environnement plus besoin de le passer en paramètre @Environment(\.managedObjectContext) private var viewContext @State private var matches: [Product]? var body: some View { List(matches ?? []) { match in HStack { Text(match.name ?? "Inconnu") Spacer() Text(match.quantity ?? "0") } } .navigationTitle("Résultats") .task { let fetchRequest: NSFetchRequest<Product> = Product.fetchRequest() // CONTAINS[cd] = insensible à la casse ET aux accents fetchRequest.predicate = NSPredicate( format: "name CONTAINS[cd] %@", name ) matches = try? viewContext.fetch(fetchRequest) } } }
Dans ContentView, le bouton Chercher pointe vers cette vue.
Il n'y a plus besoin de passer viewContext en paramètre :
HStack { Spacer() Button("Ajouter") { addProduct() } .disabled(name.isEmpty || quantity.isEmpty) Spacer() // On ne passe plus viewContext en paramètre NavigationLink(destination: ResultsView(name: name)) { Text("Chercher") } Spacer() Button("Effacer") { name = ""; quantity = "" } Spacer() }
Vue de modification EditProductView
Cette vue permet à l'utilisateur de modifier un produit existant.
Elle utilise @Environment pour le contexte et pour le dismiss,
ce qui évite tout couplage inutile.
struct EditProductView: View { // L'objet Product à modifier (observé pour réagir à ses changements) @ObservedObject var product: Product // Contexte récupéré depuis l'environnement @Environment(\.managedObjectContext) private var viewContext // Permet de fermer la vue automatiquement après la sauvegarde @Environment(\.dismiss) private var dismiss @State private var name: String = "" @State private var quantity: String = "" var body: some View { Form { Section(header: Text("Informations du produit")) { TextField("Nom du produit", text: $name) TextField("Quantité", text: $quantity) .keyboardType(.numberPad) } Section { Button("Enregistrer") { saveChanges() } .buttonStyle(.borderedProminent) .disabled(name.isEmpty || quantity.isEmpty) } } // Le titre affiche dynamiquement le nom du produit en cours de modification .navigationTitle(product.name ?? "Modifier") .onAppear { loadData() } } private func loadData() { name = product.name ?? "" quantity = product.quantity ?? "" } private func saveChanges() { product.name = name product.quantity = quantity do { try viewContext.save() dismiss() // Retour automatique à la liste après sauvegarde } catch { print("Erreur : \(error.localizedDescription)") } } }
Navigation vers la vue de modification
On remplace le simple HStack dans la liste par un NavigationLink
pour chaque produit. On utilise NavigationStack (pas NavigationView,
qui est déprécié depuis iOS 16).
// NavigationStack remplace NavigationView (déprécié depuis iOS 16) NavigationStack { List { ForEach(products) { product in // On ne passe plus viewContext, il vient de l'environnement NavigationLink(destination: EditProductView(product: product)) { HStack { Text(product.name ?? "Inconnu") .font(.headline) Spacer() Text(product.quantity ?? "0") .foregroundColor(.secondary) } } } .onDelete(perform: deleteProducts) } .navigationTitle("Inventaire") }
Gestion des erreurs plus robuste
En production, on remplace fatalError par une alerte SwiftUI :
@State private var saveErrorMessage: String? @State private var showSaveError = false private func saveContext() { do { try viewContext.save() } catch { saveErrorMessage = error.localizedDescription showSaveError = true } } // Dans body, ajouter le modificateur : .alert("Erreur de sauvegarde", isPresented: $showSaveError) { Button("OK", role: .cancel) { } } message: { Text(saveErrorMessage ?? "Erreur inconnue") }