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
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
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
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
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
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
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
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 :
#Previewaccélère l'itération x10. - Utilisez
@Bindablepour structs mutables (iOS 17+). - Limitez
@State: préférez@AppStorageou@Observable(iOS 17). - Adoptez
NavigationStack>NavigationView(déprécié). - Testez sur device : Simulator ignore parfois les gestures avancés.
Erreurs courantes à éviter
- Oublier
Identifiablesur modèles : crash List. - Muter
@Statesans$binding : état perdu. - Bloquer UI en async : toujours
Taskou.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.