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
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
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
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
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
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
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>@StateObjectpour 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
@Statemuté dansbody; utilisez computed props. - Perte de données : Toujours
saveTasks()après mutations, géreztry?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.