Lab 5.1 — Todo app

Goal

Build a minimal SwiftData-backed todo app: list of todos, add / edit / delete, mark complete, persistence across launches. Single iPhone + iPad target. Modern Swift 6 patterns: @Observable, NavigationStack, @Model, @Query, swipe actions, Form.

By the end you’ll be comfortable wiring SwiftData + SwiftUI for a basic CRUD app — the bread-and-butter app shape you’ll see in 80% of iOS jobs.

Time

90–120 minutes.

Prereqs

  • Xcode 16+
  • Comfort with Swift 6, @Observable, NavigationStack (chapter 5.5), @Model (chapter 7 forward look, but minimal knowledge here)

Setup

  1. Xcode → New Project → iOS App
  2. Interface: SwiftUI, Storage: SwiftData
  3. Name: TodoApp, organization identifier whatever
  4. Delete the boilerplate Item.swift and the sample views in ContentView.swift

Build

1. Model

Todo.swift:

import Foundation
import SwiftData

@Model
final class Todo {
    var title: String
    var notes: String
    var isCompleted: Bool
    var createdAt: Date
    var dueDate: Date?

    init(
        title: String,
        notes: String = "",
        isCompleted: Bool = false,
        dueDate: Date? = nil
    ) {
        self.title = title
        self.notes = notes
        self.isCompleted = isCompleted
        self.createdAt = .now
        self.dueDate = dueDate
    }
}

2. App entry — SwiftData container

TodoAppApp.swift:

import SwiftUI
import SwiftData

@main
struct TodoAppApp: App {
    var body: some Scene {
        WindowGroup {
            TodoListView()
        }
        .modelContainer(for: Todo.self)
    }
}

.modelContainer(for:) creates the SwiftData container, injects \.modelContext into the environment.

3. List view with @Query

TodoListView.swift:

import SwiftUI
import SwiftData

struct TodoListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Todo.createdAt, order: .reverse) private var todos: [Todo]
    @State private var showingAdd = false
    @State private var editing: Todo?

    var body: some View {
        NavigationStack {
            Group {
                if todos.isEmpty {
                    ContentUnavailableView(
                        "No todos",
                        systemImage: "checklist",
                        description: Text("Tap + to add one")
                    )
                } else {
                    List {
                        ForEach(todos) { todo in
                            TodoRow(todo: todo)
                                .contentShape(Rectangle())
                                .onTapGesture { editing = todo }
                                .swipeActions(edge: .leading) {
                                    Button {
                                        todo.isCompleted.toggle()
                                    } label: {
                                        Label(
                                            todo.isCompleted ? "Unmark" : "Complete",
                                            systemImage: todo.isCompleted ? "circle" : "checkmark.circle.fill"
                                        )
                                    }
                                    .tint(.green)
                                }
                                .swipeActions(edge: .trailing) {
                                    Button(role: .destructive) {
                                        context.delete(todo)
                                    } label: {
                                        Label("Delete", systemImage: "trash")
                                    }
                                }
                        }
                    }
                }
            }
            .navigationTitle("Todos")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showingAdd = true
                    } label: {
                        Label("Add", systemImage: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAdd) {
                NavigationStack {
                    TodoEditor(todo: nil)
                }
            }
            .sheet(item: $editing) { todo in
                NavigationStack {
                    TodoEditor(todo: todo)
                }
            }
        }
    }
}

Notes:

  • @Query is reactive — when the data changes, the view re-renders.
  • swipeActions(edge:) on both sides — leading for complete, trailing for delete.
  • ContentUnavailableView (iOS 17+) is the standard empty-state component.
  • sheet(item:) for editing existing todo (binding-based, dismisses on editing = nil).

4. Row

TodoRow.swift:

import SwiftUI
import SwiftData

struct TodoRow: View {
    @Bindable var todo: Todo

    var body: some View {
        HStack(spacing: 12) {
            Button {
                todo.isCompleted.toggle()
            } label: {
                Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                    .font(.title2)
                    .foregroundStyle(todo.isCompleted ? .green : .secondary)
            }
            .buttonStyle(.plain)
            .accessibilityLabel(todo.isCompleted ? "Mark incomplete" : "Mark complete")

            VStack(alignment: .leading, spacing: 2) {
                Text(todo.title)
                    .strikethrough(todo.isCompleted)
                    .foregroundStyle(todo.isCompleted ? .secondary : .primary)
                if let due = todo.dueDate {
                    Text(due, style: .date)
                        .font(.caption)
                        .foregroundStyle(due < .now && !todo.isCompleted ? .red : .secondary)
                }
            }
        }
        .padding(.vertical, 4)
    }
}

@Bindable enables two-way bindings on the @Model instance. Toggling isCompleted persists automatically — SwiftData detects the property mutation.

5. Editor

TodoEditor.swift:

import SwiftUI
import SwiftData

struct TodoEditor: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss

    let todo: Todo?    // nil = new

    @State private var title = ""
    @State private var notes = ""
    @State private var hasDueDate = false
    @State private var dueDate = Date.now

    var body: some View {
        Form {
            Section("Details") {
                TextField("Title", text: $title)
                TextField("Notes", text: $notes, axis: .vertical)
                    .lineLimit(3...10)
            }

            Section("Due date") {
                Toggle("Has due date", isOn: $hasDueDate.animation())
                if hasDueDate {
                    DatePicker(
                        "Due",
                        selection: $dueDate,
                        displayedComponents: [.date, .hourAndMinute]
                    )
                }
            }
        }
        .navigationTitle(todo == nil ? "New Todo" : "Edit Todo")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") { dismiss() }
            }
            ToolbarItem(placement: .confirmationAction) {
                Button(todo == nil ? "Add" : "Save") {
                    save()
                    dismiss()
                }
                .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
            }
        }
        .onAppear {
            if let todo {
                title = todo.title
                notes = todo.notes
                hasDueDate = todo.dueDate != nil
                dueDate = todo.dueDate ?? .now
            }
        }
    }

    private func save() {
        if let todo {
            todo.title = title
            todo.notes = notes
            todo.dueDate = hasDueDate ? dueDate : nil
        } else {
            let new = Todo(
                title: title,
                notes: notes,
                dueDate: hasDueDate ? dueDate : nil
            )
            context.insert(new)
        }
    }
}

6. Run & verify

  • Add 3 todos
  • Mark one complete
  • Edit one (change title, add a due date)
  • Delete one with swipe
  • Kill app, relaunch — data persists

Stretch goals

  1. Filters/segments: Add Picker(.segmented) at top: All / Active / Completed. Use a @Query(filter:) or local filtering.
  2. Categories: Add @Model Category with @Relationship from Todo to Category. Add a category picker in the editor.
  3. Search: Add .searchable(text:) on TodoListView, filter the list.
  4. Pull to refresh: .refreshable { try? await Task.sleep(for: .seconds(1)) } (no-op, but shows the pattern).
  5. Notifications: Schedule a local notification when a todo has a due date in the future.
  6. iPad split view: Wrap in NavigationSplitView — list on left, editor on right.
  7. Reorder: Add .onMove for manual ordering, store order: Int in model.
  8. Watch app: Add a watchOS target that reads the same SwiftData (via App Group + CloudKit sync).

Notes & troubleshooting

  • @Bindable requires the type to be an @Observable or @Model. Won’t compile on plain classes.
  • @Query re-runs whenever data changes. Don’t filter in the view body if you can express it in @Query(filter: #Predicate { ... }) for performance.
  • SwiftData with iCloud: add .modelContainer(for: Todo.self, isAutosaveEnabled: true) and configure CloudKit container in entitlements.
  • Editing pattern: passing the Todo directly and using @Bindable lets edits commit live as the user types. If you prefer “Cancel” to discard changes, use the local @State + save() pattern as shown (changes only persist on Save).
  • Sheet binding gotcha: editing = todo triggers the sheet(item:). Setting editing = nil dismisses. Tapping outside or the cancel button also nils it via dismiss() — works because sheet(item:) is bound to $editing.

Where to next

Lab 5.2 (Animated dashboard) explores Canvas, PhaseAnimator, matchedGeometryEffect — the animation-heavy side of SwiftUI.


Next: Lab 5.2 — Animated dashboard