Skip to content
Learni
View all tutorials
Développement iOS

How to Build an Advanced Todo App with SwiftUI in 2026

Lire en français

Introduction

SwiftUI, Apple's declarative framework since iOS 13, revolutionizes iOS UI development in 2026 with live previews and native state management. This intermediate tutorial guides you through building a complete Todo app: dynamic lists, add/delete, detail navigation, local persistence, and mock API task fetching.

Why it matters: Modern apps demand smooth reactivity without UIKit boilerplate. You'll master @State, @Binding, NavigationStack, @AppStorage, and Task for async ops. By the end, you'll have a bookmarkable, testable app in Xcode 16+ simulator. Time: 30 min. Ready to level up your iOS skills? (112 words)

Prerequisites

  • Xcode 16+ (free on Mac App Store)
  • Basic Swift knowledge (structs, optionals)
  • iPhone simulator set up
  • No external dependencies: pure SwiftUI
  • Intermediate level: familiarity with closures and protocols

Basic view with static list

ContentView.swift
import SwiftUI

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

struct ContentView: View {
    let todos = [
        TodoItem(title: "Learn SwiftUI"),
        TodoItem(title: "Build the Todo App", 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("My Todos")
        }
    }
}

#Preview {
    ContentView()
}

This code creates a basic ContentView with an identifiable TodoItem model and a static list using List. NavigationStack (iOS 16+) handles future navigation. The preview lets you test instantly. Always use Identifiable arrays to avoid List crashes.

Dynamic state management

Now add mutable state with @State. Think of state like a spring: change it, and the view redraws automatically. We'll add a button to toggle tasks.

Add state and 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: "Learn SwiftUI"),
        TodoItem(title: "Build the Todo App", 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("My Todos")
        }
    }
}

#Preview {
    ContentView()
}

@State makes todos mutable; $todos provides @Binding for ForEach. The .onTapGesture toggles state, triggering a redraw. Always use $ for mutable bindings in List, or changes won't persist.

Adding tasks

@Binding connects parent-child: change the child, the parent updates. Introduce a Sheet for adding todos via a bound TextField.

Add sheet with form

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: "Learn SwiftUI"),
        TodoItem(title: "Build the Todo App", 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("My Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("New Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Add")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Add") {
                                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 for showingAddSheet and newTodoTitle; sheet with binding. .onDelete enables swipe-to-delete. Button disables if empty (.disabled). .toolbar positions the + action cleanly over deprecated .navigationBarItems.

Local persistence

Save data with @AppStorage: like a vault shared app-wide, serializing to UserDefaults. Ideal for simple todos.

Persistence with 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("My Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("New Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Add")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Add") {
                                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 conforms to Codable. @AppStorage("todos") stores JSON Data. saveTodos/loadTodos encode/decode. Called on onAppear, toggle, and delete. Skip direct UserDefaults: @AppStorage handles redraws automatically.

Navigation to details

NavigationLink for details: like a portal to a child view. Add a TodoDetail view to edit the title.

Details with NavigationLink

TodoDetailView.swift
import SwiftUI

struct TodoDetailView: View {
    @Bindable var todo: TodoItem
    @Environment(\dismiss) private var dismiss
    
    var body: some View {
        Form {
            TextField("Title", text: $todo.title)
            Toggle("Completed", isOn: $todo.isCompleted)
        }
        .navigationTitle("Todo Detail")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button("Save") {
                    dismiss()
                }
            }
        }
    }
}

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

@Bindable (iOS 17+) for bidirectional binding. @Environment(\dismiss) closes the view. Integrate via NavigationLink(value: todo) in ContentView. @Bindable is safer than @Binding for complex structs.

Integrate details in 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("My Todos")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("New Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Add")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Add") {
                                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+) routes to TodoDetailView. Detail changes persist via @Bindable. Add import SwiftUI and TodoDetailView to your project. Remove redundant list toggle/details.

Asynchronous fetch

Simulate an API with Task and async/await: like a delivery service that loads without blocking the UI.

Mock API fetch

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("My Todos")
            .overlay {
                if isLoading {
                    ProgressView("Loading...")
                }
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("+") {
                        showingAddSheet = true
                    }
                }
                ToolbarItem {
                    Button("Refresh") {
                        Task { await fetchTodos() }
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                NavigationStack {
                    Form {
                        TextField("New Todo", text: $newTodoTitle)
                    }
                    .navigationTitle("Add")
                    .toolbar {
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Add") {
                                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("Fetch error: \(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 runs fetchTodos() on mount. async/await + URLSession fetches from JSONPlaceholder (mock todos). defer handles loading. ProgressView overlay. Adapt TodoItem for real APIs; handle errors with do-catch.

Best practices

  • Always preview: #Preview speeds iteration x10.
  • Use @Bindable for mutable structs (iOS 17+).
  • Limit @State: prefer @AppStorage or @Observable (iOS 17).
  • Adopt NavigationStack over deprecated NavigationView.
  • Test on device: Simulator sometimes skips advanced gestures.

Common errors to avoid

  • Forget Identifiable on models: List crashes.
  • Mutate @State without $ binding: state lost.
  • Block UI in async: always use Task or .task.
  • Skip persistence: app empty on relaunch.

Next steps

Integrate SwiftData for native ORM or Combine for reactive streams. Explore Advanced SwiftUI Animations. Join our Learni iOS trainings for live masterclasses. Official docs: SwiftUI by Apple.

How to Build Todo App with SwiftUI in 2026 | Learni