Skip to content
Learni
Voir tous les tutoriels
Développement iOS

Comment créer une app Todo SwiftUI en 2026

Read in English

Introduction

SwiftUI, le framework déclaratif d'Apple pour les interfaces iOS, macOS et plus, a évolué en 2026 avec SwiftUI 6 : meilleure performance, animations fluides et intégration IA native. Ce tutoriel intermédiaire vous guide pour créer une app Todo List complète, gérant état réactif (@State, @Binding), navigation (NavigationStack), listes interactives (swipe to delete), formulaires modaux et persistance locale (@AppStorage).

Pourquoi c'est crucial ? Les apps iOS modernes exigent une UI réactive sans boilerplate UIKit. Vous apprendrez à structurer une app scalable, éviter les pièges courants comme les re-renders inutiles, et optimiser pour iOS 20. À la fin, vous aurez une app copier-collable, prête pour l'App Store. Idéal pour devs avec bases Swift qui veulent passer pro. (128 mots)

Prérequis

  • Xcode 16+ (version 2026 recommandée)
  • Connaissances de base en Swift (structs, optionals, closures)
  • Simulateur iOS 20 ou appareil physique
  • Projet SwiftUI iOS vide (créez-le via File > New > Project > App)

Structure de l'app principale

ToDoApp.swift
import SwiftUI

@main
struct ToDoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

#Preview {
    ContentView()
}

Ce fichier d'entrée définit l'app SwiftUI avec @main. WindowGroup gère les fenêtres multi-plateformes. Le #Preview permet des previews en temps réel dans Xcode, accélérant le dev itératif. Évitez d'ajouter du state ici pour garder l'app légère.

Modèle de données Task

Définissons un modèle Task simple mais extensible : identifiant unique, titre, état complet/incomplet et date. Utilisez Identifiable pour les listes SwiftUI et Codable pour la persistance future. Analogy : comme un blueprint Lego, réutilisable partout.

Définir le modèle Task

Task.swift
import Foundation

struct Task: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
    var createdAt: Date = Date()
    
    mutating func toggle() {
        isCompleted.toggle()
    }
}

extension Task: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Ce struct est Identifiable pour List, Codable pour JSON persistence. La méthode toggle() encapsule la logique métier. Hashable extension évite les bugs de comparaison en Set/Dictionary. Piège : oubliez UUID() unique cause doublons en List.

ContentView avec liste interactive

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var tasks: [Task] = []
    @State private var showingAddTask = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($tasks) { $task in
                    TaskRowView(task: $task)
                }
                .onDelete(perform: deleteTasks)
            }
            .navigationTitle("Mes Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddTask = true
                    }
                }
            }
            .sheet(isPresented: $showingAddTask) {
                AddTaskView(tasks: $tasks)
            }
        }
    }
    
    private func deleteTasks(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
    }
}

#Preview {
    ContentView()
}

@State gère l'état local réactif des tasks. NavigationStack (iOS 16+) remplace NavigationView pour stack-based nav. ForEach($tasks) avec binding $task permet edits inline. Swipe-to-delete via .onDelete. Sheet modal pour ajout évite surcharge UI.

Ligne de tâche et vue d'ajout

Créez une TaskRowView pour customiser chaque item : checkbox, strike-through, timestamp. Puis AddTaskView avec formulaire validé. @Binding propage les changements parents/enfants, comme un flux unidirectionnel réactif.

TaskRowView et AddTaskView

TaskRowView.swift
import SwiftUI

struct TaskRowView: View {
    @Binding var task: Task
    
    var body: some View {
        HStack {
            Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                .foregroundStyle(task.isCompleted ? .green : .gray)
                .onTapGesture {
                    task.toggle()
                }
            
            Text(task.title)
                .strikethrough(task.isCompleted)
                .foregroundStyle(task.isCompleted ? .secondary : .primary)
            
            Spacer()
            
            Text(task.createdAt, style: .date)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding(.vertical, 4)
    }
}

struct AddTaskView: View {
    @Binding var tasks: [Task]
    @Environment(\dismiss) private var dismiss
    @State private var newTaskTitle = ""
    
    var body: some View {
        NavigationStack {
            Form {
                TextField("Titre de la tâche", text: $newTaskTitle)
            }
            .navigationTitle("Nouvelle tâche")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Ajouter") {
                        let newTask = Task(title: newTaskTitle)
                        tasks.append(newTask)
                        dismiss()
                    }
                    .disabled(newTaskTitle.trimmingCharacters(in: .whitespaces).isEmpty)
                }
                ToolbarItem(placement: .topBarLeading) {
                    Button("Annuler") { dismiss() }
                }
            }
        }
    }
}

#Preview {
    TaskRowView(task: .constant(Task(title: "Test")))
}

#Preview {
    AddTaskView(tasks: .constant([]))
}

TaskRowView utilise @Binding pour muter la task parent. onTapGesture toggle visuel. AddTaskView valide input (disabled si vide). @Environment(dismiss) ferme sheets nativement. Form auto-valide et stylise. Piège : sans trimmingCharacters, espaces causent tâches vides.

Ajouter persistance avec @AppStorage

PersistentTasks.swift
import SwiftUI

@Observable
class TaskStore {
    var tasks: [Task] = []
    
    private let storageKey = "tasks"
    
    init() {
        loadTasks()
    }
    
    func addTask(_ task: Task) {
        tasks.append(task)
        saveTasks()
    }
    
    func deleteTasks(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
        saveTasks()
    }
    
    func toggleTask(id: UUID) {
        if let index = tasks.firstIndex(where: { $0.id == id }) {
            tasks[index].toggle()
            saveTasks()
        }
    }
    
    private func saveTasks() {
        if let data = try? JSONEncoder().encode(tasks) {
            UserDefaults.standard.set(data, forKey: storageKey)
        }
    }
    
    private func loadTasks() {
        if let data = UserDefaults.standard.data(forKey: storageKey),
           let savedTasks = try? JSONDecoder().decode([Task].self, from: data) {
            tasks = savedTasks
        }
    }
}

struct PersistentContentView: View {
    @State private var taskStore = TaskStore()
    @State private var showingAddTask = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(taskStore.tasks) { task in
                    TaskRowView(task: task)
                        .onTapGesture {
                            taskStore.toggleTask(id: task.id)
                        }
                }
                .onDelete(perform: taskStore.deleteTasks)
            }
            .navigationTitle("Todos Persistants")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") { showingAddTask = true }
                }
            }
            .sheet(isPresented: $showingAddTask) {
                AddPersistentTaskView(taskStore: taskStore)
            }
        }
    }
}

#Preview {
    PersistentContentView()
}

@Observable (Swift 5.9+) remplace ObservableObject pour data flow moderne. TaskStore singleton-like persiste via UserDefaults/JSON. addTask, delete, toggle sauvent auto. Remplacez @State tasks par ce store dans ContentView pour upgrade. Piège : sans try?, crashes sur decode fail.

Intégration finale et tests

Remplacez ContentView par PersistentContentView. Testez : ajoutez/supprimez/togglez, relancez app – données persistent. Ajoutez animations (.transition) pour polish pro.

Vue détail avec NavigationLink

TaskDetailView.swift
import SwiftUI

struct TaskDetailView: View {
    let task: Task
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                HStack {
                    Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        .font(.largeTitle)
                        .foregroundStyle(task.isCompleted ? .green : .gray)
                    Text(task.title)
                        .font(.title)
                        .fontWeight(.semibold)
                }
                
                Label("Créée le", systemImage: "calendar")
                    .labelStyle(.titleOnly)
                Text(task.createdAt, style: .date)
                    .foregroundStyle(.secondary)
                
                Divider()
                
                if task.isCompleted {
                    Text("✅ Terminée !")
                        .font(.headline)
                        .foregroundStyle(.green)
                } else {
                    Text("⏳ En cours")
                        .font(.headline)
                        .foregroundStyle(.orange)
                }
            }
            .padding()
        }
        .navigationTitle(task.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    NavigationStack {
        TaskDetailView(task: Task(title: "Exemple"))
    }
}

Vue détail pour deep dive task. ScrollView + VStack pour layout responsive. Labels et icons SF Symbols pour UX native Apple. Intégrez via NavigationLink(value: task) dans List (avec .navigationDestination). Évite surcharge List, améliore accessibilité.

Bonnes pratiques

  • Séparez concerns : Modèles purs, Views stateless autant que possible, Stores pour data.
  • Utilisez @Observable > @StateObject pour perf 2026.
  • Ajoutez .animation(.spring(), value: tasks) pour transitions fluides.
  • Testez sur device : Simulateur lent pour listes longues.
  • Validez toujours inputs : trimmingCharacters(in: .whitespaces) + min length.

Erreurs courantes à éviter

  • Re-renders infinis : Évitez @State muté dans body ; utilisez computed props.
  • Perte de données : Toujours saveTasks() après mutations, gérez try? gracefully.
  • Navigation buggée : Préférez NavigationStack > NavigationView (déprécié).
  • Previews cassées : Fournissez mocks .constant() pour bindings.

Pour aller plus loin

Passez à CloudKit/SwiftData pour sync multi-appareils. Explorez Animations avancées SwiftUI. Inscrivez-vous à nos formations Learni iOS/SwiftUI pour masterclasses live et projets réels.