← Accueil IFM025921 · Module 10 · Laboratoire

SwiftUI & Core Data

Module 10 Laboratoire pratique · Inventaire de produits

Objectif : Construire une application d'inventaire en ajoutant manuellement tous les composants Core Data sans cocher l'option automatique d'Xcode afin de bien comprendre chaque pièce du puzzle.

01 Créer le projet « CoreDataDemo »

  1. Lancez Xcode et cliquez sur « Create a new Xcode project ».
  2. Sélectionnez iOS puis App.
  3. Nommez le projet CoreDataDemo.
  4. Dans la partie Storage, laissez sur None (ne cochez pas Core Data).
  5. Choisissez l'emplacement et cliquez sur Finish.
En n'utilisant pas l'option automatique, on crée chaque fichier Core Data nous-mêmes. C'est la meilleure façon de comprendre ce que fait Xcode en coulisse.

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.

  1. Appuyez sur ⌘ N pour créer un nouveau fichier.
  2. Dans la section Core Data, sélectionnez Data Model, puis Next.
  3. Nommez le fichier Products et cliquez sur Create.

Un fichier Products.xcdatamodeld apparaît dans l'arborescence du projet.

Définir une entité

  1. Ouvrez Products.xcdatamodeld dans Xcode.
  2. Cliquez sur Add Entity en bas de l'éditeur.
  3. Double-cliquez sur le nom par défaut et renommez-le Product.
  4. Dans la section Attributes, cliquez sur + deux fois pour ajouter :
name
Type String le nom du produit.
quantity
Type String la quantité. On utilise String pour simplifier l'exercice. En production, on préférerait 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.

  1. Appuyez sur ⌘ N, choisissez Swift File.
  2. Nommez-le Persistence.swift.
  3. Remplacez le contenu par le code suivant :
Persistence.swift
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
    }
}
NSPersistentContainer
Représente l'ensemble de la pile Core Data il crée et gère tous les composants automatiquement.
loadPersistentStores
Initialise la base de données SQLite sur disque. On attrape les erreurs ici.
automaticallyMergesChangesFromParent
Assure que le contexte principal reste synchronisé si des données sont modifiées en arrière-plan.

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.

CoreDataDemoApp.swift
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)
        }
    }
}
Pattern environnement SwiftUI : En injectant le contexte ici, n'importe quelle vue dans l'arbre peut le récupérer avec @Environment(\.managedObjectContext) sans qu'on ait à le passer manuellement de vue en vue.

05 ContentView Interface principale

ContentView.swift
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")
        }
    }
}
@FetchRequest(sortDescriptors: [])
Récupère tous les objets Product de la base. La liste se met à jour automatiquement dès qu'un produit est ajouté ou supprimé.
FetchedResults<Product>
Tableau réactif fourni par Core Data. SwiftUI re-dessine la vue à chaque changement.
.disabled(name.isEmpty || ...)
Empêche d'ajouter un produit vide. Bonne pratique à systématiser.
.onDelete
Permet de supprimer un produit en glissant vers la gauche dans la liste.

06 Fonctions CRUD

On ajoute ces fonctions dans une extension de ContentView pour garder le code bien organisé.

ContentView.swift (extension)
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)")
        }
    }
}
addProduct()
Crée un objet Product, assigne les valeurs, sauvegarde, puis vide les champs.
deleteProducts(offsets:)
Supprime les produits aux indices fournis par .onDelete, puis sauvegarde.
saveContext()
Appelle viewContext.save() pour persister les changements dans SQLite.
fatalError en production ? Dans une vraie application, on remplacerait 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.

Bonne pratique : 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.
ContentView.swift
//  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.

ResultsView.swift
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 :

ContentView.swift boutons
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.

EditProductView.swift
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)")
        }
    }
}
@ObservedObject var product
Observe l'objet Core Data en direct. Si une autre vue le modifie, celle-ci se met aussi à jour.
@Environment(\.dismiss)
Permet de fermer la vue programmatiquement après une action. Pratique après une sauvegarde réussie.
.onAppear { loadData() }
Pré-remplit les champs avec les données existantes du produit à l'ouverture de la vue.

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

ContentView.swift liste cliquable
//  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 :

ContentView.swift
@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")
}