Skip to content
Learni
View all tutorials
Développement iOS

How to Build a Todo App with SwiftUI in 2026

Lire en français

Introduction

SwiftUI, Apple's declarative framework for iOS, macOS, and beyond, has evolved in 2026 with SwiftUI 6: improved performance, smooth animations, and native AI integration. This intermediate tutorial walks you through building a complete Todo List app, handling reactive state (@State, @Binding), navigation (NavigationStack), interactive lists (swipe to delete), modal forms, and local persistence (@AppStorage).

Why it matters: Modern iOS apps demand responsive UIs without UIKit boilerplate. You'll learn to structure scalable apps, sidestep common issues like unnecessary re-renders, and optimize for iOS 20. At the end, you'll have copy-pasteable code ready for the App Store. Perfect for devs with Swift basics aiming to level up. (128 words)

Prerequisites

  • Xcode 16+ (2026 version recommended)
  • Basic Swift knowledge (structs, optionals, closures)
  • iOS 20 simulator or physical device
  • Empty SwiftUI iOS project (create via File > New > Project > App)

Main App Structure

ToDoApp.swift
import SwiftUI

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

#Preview {
    ContentView()
}

This entry-point file defines the SwiftUI app with @main. WindowGroup handles multi-platform windows. The #Preview enables live previews in Xcode, speeding up iterative development. Avoid adding state here to keep the app lightweight.

Task Data Model

Let's define a simple yet extensible Task model: unique ID, title, completion status, and creation date. Use Identifiable for SwiftUI lists and Codable for future persistence. Think of it like a Lego blueprint—reusable everywhere.

Define the Task Model

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

This struct is Identifiable for List usage and Codable for JSON persistence. The toggle() method encapsulates business logic. The Hashable extension prevents comparison bugs in Sets/Dictionaries. Pitfall: Forgetting unique UUID() causes duplicates in Lists.

ContentView with Interactive List

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 manages reactive local state for tasks. NavigationStack (iOS 16+) replaces NavigationView for stack-based navigation. ForEach($tasks) with binding $task enables inline edits. Swipe-to-delete via .onDelete. Modal sheet for adding avoids UI overload.

Task Row and Add View

Create a TaskRowView to customize each item: checkbox, strikethrough, timestamp. Then build AddTaskView with a validated form. @Binding propagates changes between parent and child, like unidirectional reactive data flow.

TaskRowView and 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 uses @Binding to mutate the parent task. onTapGesture handles visual toggling. AddTaskView validates input (disabled if empty). @Environment(dismiss) closes sheets natively. Form auto-validates and styles. Pitfall: Without trimmingCharacters, spaces create empty tasks.

Add Persistence with TaskStore

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+) replaces ObservableObject for modern data flow. TaskStore acts like a singleton to persist via UserDefaults/JSON. addTask, delete, and toggle auto-save. Swap out @State tasks in ContentView for this upgrade. Pitfall: Without try?, decoding failures crash the app.

Final Integration and Testing

Replace ContentView with PersistentContentView. Test by adding/deleting/toggling tasks, then restart the app—data persists. Add animations (.transition) for pro polish.

Task Detail View with 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"))
    }
}

Detail view for in-depth task inspection. ScrollView + VStack for responsive layout. SF Symbols and Labels for native Apple UX. Integrate via NavigationLink(value: task) in the List (with .navigationDestination). Avoids List clutter and boosts accessibility.

Best Practices

  • Separate concerns: Pure models, stateless Views where possible, Stores for data.
  • Use @Observable over @StateObject for 2026 performance.
  • Add .animation(.spring(), value: tasks) for smooth transitions.
  • Test on device: Simulator lags with long lists.
  • Always validate inputs: trimmingCharacters(in: .whitespaces) + min length.

Common Errors to Avoid

  • Infinite re-renders: Avoid mutating @State in body; use computed properties.
  • Data loss: Always call saveTasks() after mutations, handle try? gracefully.
  • Navigation bugs: Prefer NavigationStack over deprecated NavigationView.
  • Broken previews: Provide mocks like .constant() for bindings.

Next Steps

Upgrade to CloudKit/SwiftData for multi-device sync. Explore Advanced SwiftUI Animations. Join our Learni iOS/SwiftUI courses for live masterclasses and real-world projects.