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

Comment créer une app Todo avancée avec SwiftUI en 2026

Read in English

Introduction

SwiftUI, framework déclaratif d'Apple depuis iOS 13, révolutionne le développement d'interfaces iOS en 2026 avec des previews en temps réel et une gestion d'état native. Ce tutoriel intermédiaire vous guide pour créer une app Todo complète : liste dynamique, ajout/suppression, navigation vers détails, persistance locale et fetch de tâches mockées via API.

Pourquoi c'est crucial ? Les apps modernes exigent une réactivité fluide sans boilerplate UIKit. Vous apprendrez @State, @Binding, NavigationStack, @AppStorage et Task pour l'asynchrone. À la fin, vous aurez une app bookmarkable, testable en simulator Xcode 16+. Durée : 30 min de mise en œuvre. Prêt à booster vos skills iOS ? (128 mots)

Prérequis

  • Xcode 16+ (gratuit sur Mac App Store)
  • Connaissances de base en Swift (structs, optionals)
  • Simulator iPhone configuré
  • Pas de dépendances externes : pur SwiftUI
  • Niveau intermédiaire : familiarité avec closures et protocols

View de base avec liste statique

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    let todos = [
        TodoItem(title: "Apprendre SwiftUI"),
        TodoItem(title: "Coder l'app Todo", isCompleted: true)
    ]
    
    var body: some View {
        NavigationStack {
            List(todos) { todo in
                HStack {
                    Text(todo.title)
                    Spacer()
                    Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                }
            }
            .navigationTitle("Mes Todos")
        }
    }
}

#Preview {
    ContentView()
}

Ce code crée une ContentView basique avec un modèle TodoItem identifiable et une liste statique via List. NavigationStack (iOS 16+) gère la navigation future. La preview permet de tester instantanément. Évitez les arrays non-Identifiable pour prévenir les crashes en List.

Gestion d'état dynamique

Passez maintenant à l'état mutable avec @State. Imaginez l'état comme un ressort : modifiez-le, la view se redessine automatiquement. Nous ajoutons un bouton pour toggler les tâches.

Ajouter état et toggle

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    @State private var todos: [TodoItem] = [
        TodoItem(title: "Apprendre SwiftUI"),
        TodoItem(title: "Coder l'app Todo", isCompleted: true)
    ]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Text(todo.title)
                        Spacer()
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(todo.isCompleted ? .green : .gray)
                            .onTapGesture {
                                todo.isCompleted.toggle()
                            }
                    }
                }
            }
            .navigationTitle("Mes Todos")
        }
    }
}

#Preview {
    ContentView()
}

@State rend todos mutable ; $todos expose des @Binding pour ForEach. Le .onTapGesture toggle l'état, déclenchant un redraw. Utilisez toujours $ pour les bindings mutables en List, sinon les changements ne persistent pas.

Ajout de tâches

Binding relie parent-enfant : modifiez l'enfant, le parent suit. Introduisons un Sheet pour ajouter des todos via TextField bindé.

Formulaire d'ajout avec Sheet

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    @State private var todos: [TodoItem] = [
        TodoItem(title: "Apprendre SwiftUI"),
        TodoItem(title: "Coder l'app Todo", isCompleted: true)
    ]
    @State private var showingAddSheet = false
    @State private var newTodoTitle = ""
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Text(todo.title)
                        Spacer()
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(todo.isCompleted ? .green : .gray)
                            .onTapGesture {
                                todo.isCompleted.toggle()
                            }
                    }
                }
                .onDelete(perform: deleteTodos)
            }
            .navigationTitle("Mes Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("Nouveau Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Ajouter")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Ajouter") {
                                let todo = TodoItem(title: newTodoTitle)
                                todos.append(todo)
                                newTodoTitle = ""
                                showingAddSheet = false
                            }
                            .disabled(newTodoTitle.isEmpty)
                        }
                    }
                }
            }
        }
    }
    
    private func deleteTodos(at offsets: IndexSet) {
        todos.remove(atOffsets: offsets)
    }
}

#Preview {
    ContentView()
}

@State pour showingAddSheet et newTodoTitle ; sheet avec binding. .onDelete swipe-to-delete. Le bouton est désactivé si vide (disabled). .toolbar positionne l'action + proprement que .navigationBarItems (déprécié).

Persistance locale

Sauvegardez les données avec @AppStorage : comme un coffre-fort partagé app-wide, sérialise en UserDefaults. Parfait pour todos simples.

Persistance avec AppStorage

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    @AppStorage("todos") private var todosData: Data = Data()
    @State private var todos: [TodoItem] = []
    @State private var showingAddSheet = false
    @State private var newTodoTitle = ""
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Text(todo.title)
                        Spacer()
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(todo.isCompleted ? .green : .gray)
                            .onTapGesture {
                                todo.isCompleted.toggle()
                                saveTodos()
                            }
                    }
                }
                .onDelete(perform: deleteTodos)
            }
            .navigationTitle("Mes Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("Nouveau Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Ajouter")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Ajouter") {
                                let todo = TodoItem(title: newTodoTitle)
                                todos.append(todo)
                                newTodoTitle = ""
                                showingAddSheet = false
                                saveTodos()
                            }
                            .disabled(newTodoTitle.isEmpty)
                        }
                    }
                }
            }
            .onAppear {
                loadTodos()
            }
        }
    }
    
    private func saveTodos() {
        if let data = try? JSONEncoder().encode(todos) {
            todosData = data
        }
    }
    
    private func loadTodos() {
        if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
            todos = decodedTodos
        }
    }
    
    private func deleteTodos(at offsets: IndexSet) {
        todos.remove(atOffsets: offsets)
        saveTodos()
    }
}

#Preview {
    ContentView()
}

TodoItem conforme Codable. @AppStorage("todos") stocke Data JSON. saveTodos/loadTodos encode/décodent. Appel en onAppear, toggle et delete. Évitez UserDefaults direct : @AppStorage gère les redraws automatiquement.

Navigation vers détails

NavigationLink pour détails : comme un portail vers une vue enfant. Ajoutons une vue TodoDetail éditant le titre.

Détails avec NavigationLink

TodoDetailView.swift
import SwiftUI

struct TodoDetailView: View {
    @Bindable var todo: TodoItem
    @Environment(\dismiss) private var dismiss
    
    var body: some View {
        Form {
            TextField("Titre", text: $todo.title)
            Toggle("Terminé", isOn: $todo.isCompleted)
        }
        .navigationTitle("Détail Todo")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button("Sauvegarder") {
                    dismiss()
                }
            }
        }
    }
}

#Preview {
    NavigationStack {
        TodoDetailView(todo: TodoItem(title: "Exemple"))
    }
}

@Bindable (iOS 17+) pour binding bidirectionnel. @Environment(\dismiss) ferme la vue. Intégrez via NavigationLink(value: todo) dans ContentView. @Bindable est plus sûr que @Binding pour les structs complexes.

Intégration détails dans ContentView

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    @AppStorage("todos") private var todosData: Data = Data()
    @State private var todos: [TodoItem] = []
    @State private var showingAddSheet = false
    @State private var newTodoTitle = ""
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    NavigationLink(value: todo) {
                        HStack {
                            Text(todo.title)
                            Spacer()
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .foregroundStyle(todo.isCompleted ? .green : .gray)
                        }
                    }
                }
                .navigationDestination(for: TodoItem.self) { todo in
                    TodoDetailView(todo: todo)
                }
            }
            .navigationTitle("Mes Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("Nouveau Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Ajouter")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Ajouter") {
                                let todo = TodoItem(title: newTodoTitle)
                                todos.append(todo)
                                newTodoTitle = ""
                                showingAddSheet = false
                                saveTodos()
                            }
                            .disabled(newTodoTitle.isEmpty)
                        }
                    }
                }
            }
            .onAppear {
                loadTodos()
            }
        }
    }
    
    private func saveTodos() {
        if let data = try? JSONEncoder().encode(todos) {
            todosData = data
        }
    }
    
    private func loadTodos() {
        if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
            todos = decodedTodos
        }
    }
}

#Preview {
    ContentView()
}

NavigationLink(value:) + .navigationDestination (iOS 16+) route vers TodoDetailView. Modifs dans détail persistent via @Bindable. Ajoutez import SwiftUI et TodoDetailView au projet. Supprimez toggle/détail redondants de la list.

Fetch asynchrone

Simulez une API avec Task et async/await : comme un livreur qui charge sans bloquer l'UI.

Fetch mock API

ContentView.swift
import SwiftUI

struct TodoItem: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
}

struct ContentView: View {
    @AppStorage("todos") private var todosData: Data = Data()
    @State private var todos: [TodoItem] = []
    @State private var showingAddSheet = false
    @State private var newTodoTitle = ""
    @State private var isLoading = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    NavigationLink(value: todo) {
                        HStack {
                            Text(todo.title)
                            Spacer()
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .foregroundStyle(todo.isCompleted ? .green : .gray)
                        }
                    }
                }
                .navigationDestination(for: TodoItem.self) { todo in
                    TodoDetailView(todo: todo)
                }
            }
            .navigationTitle("Mes Todos")
            .overlay {
                if isLoading {
                    ProgressView("Chargement...")
                }
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
                ToolbarItem {
                    Button("Refresh") {
                        Task { await fetchTodos() }
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("Nouveau Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Ajouter")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Ajouter") {
                                let todo = TodoItem(title: newTodoTitle)
                                todos.append(todo)
                                newTodoTitle = ""
                                showingAddSheet = false
                                saveTodos()
                            }
                            .disabled(newTodoTitle.isEmpty)
                        }
                    }
                }
            }
            .task {
                await fetchTodos()
            }
            .onAppear {
                loadTodos()
            }
        }
    }
    
    private func fetchTodos() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            // Mock API delay
            try await Task.sleep(nanoseconds: 1_000_000_000)
            
            let url = URL(string: "https://jsonplaceholder.typicode.com/todos?_limit=5")!
            let (data, _) = try await URLSession.shared.data(from: url)
            
            todos = try JSONDecoder().decode([TodoItem].self, from: data)
            saveTodos()
        } catch {
            print("Erreur fetch: \(error)")
        }
    }
    
    private func saveTodos() {
        if let data = try? JSONEncoder().encode(todos) {
            todosData = data
        }
    }
    
    private func loadTodos() {
        if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
            todos = decodedTodos
        }
    }
}

#Preview {
    ContentView()
}

.task lance fetchTodos() au montage. async/await + URLSession fetch JSONPlaceholder (mock todos). defer gère loading. ProgressView overlay. Adaptez TodoItem à l'API réelle ; gérez erreurs avec do-catch.

Bonnes pratiques

  • Prévisualisez toujours : #Preview accélère l'itération x10.
  • Utilisez @Bindable pour structs mutables (iOS 17+).
  • Limitez @State : préférez @AppStorage ou @Observable (iOS 17).
  • Adoptez NavigationStack > NavigationView (déprécié).
  • Testez sur device : Simulator ignore parfois les gestures avancés.

Erreurs courantes à éviter

  • Oublier Identifiable sur modèles : crash List.
  • Muter @State sans $ binding : état perdu.
  • Bloquer UI en async : toujours Task ou .task.
  • Ignorer persistance : app vide au relance.

Pour aller plus loin

Intégrez SwiftData pour ORM natif ou Combine pour reactive streams. Explorez Animations avancées SwiftUI. Rejoignez nos formations Learni iOS pour masterclass live. Docs officielles : SwiftUI Apple.