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
- Xcode → New Project → iOS App
- Interface: SwiftUI, Storage: SwiftData
- Name:
TodoApp, organization identifier whatever - Delete the boilerplate
Item.swiftand the sample views inContentView.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:
@Queryis 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 onediting = 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
- Filters/segments: Add
Picker(.segmented)at top: All / Active / Completed. Use a@Query(filter:)or local filtering. - Categories: Add
@Model Categorywith@RelationshipfromTodotoCategory. Add a category picker in the editor. - Search: Add
.searchable(text:)onTodoListView, filter the list. - Pull to refresh:
.refreshable { try? await Task.sleep(for: .seconds(1)) }(no-op, but shows the pattern). - Notifications: Schedule a local notification when a todo has a due date in the future.
- iPad split view: Wrap in
NavigationSplitView— list on left, editor on right. - Reorder: Add
.onMovefor manual ordering, storeorder: Intin model. - Watch app: Add a watchOS target that reads the same SwiftData (via App Group + CloudKit sync).
Notes & troubleshooting
@Bindablerequires the type to be an@Observableor@Model. Won’t compile on plain classes.@Queryre-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
Tododirectly and using@Bindablelets 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 = todotriggers thesheet(item:). Settingediting = nildismisses. Tapping outside or the cancel button also nils it viadismiss()— works becausesheet(item:)is bound to$editing.
Where to next
Lab 5.2 (Animated dashboard) explores Canvas, PhaseAnimator, matchedGeometryEffect — the animation-heavy side of SwiftUI.