What You Will Build
A functional todo list app with an add-task text field, toggle-to-complete checkmarks, strikethrough styling on completed items, and swipe-to-delete functionality. New tasks are inserted at the top of the list with animation. This is the foundational pattern for any list-based app: task managers, shopping lists, note-taking apps, and inbox UIs.
Why This Pattern Matters
Lists with interactive rows are the backbone of iOS apps. This tutorial covers the key patterns: ForEach with $binding, .swipeActions, inserting and removing items with animation, and conditional styling based on item state.
Key SwiftUI Concepts
- ForEach with $binding using
ForEach($todos)to get a mutable binding to each item. - .swipeActions for the swipe-to-delete gesture on each row.
- withAnimation on array mutations for smooth insert and delete animations.
Step 1: The Todo Model
private struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
Step 2: The Add Task Bar
@State private var todos: [TodoItem] = [
.init(title: "Design the UI", isCompleted: true),
.init(title: "Implement navigation"),
.init(title: "Add animations"),
.init(title: "Write unit tests"),
.init(title: "Submit to App Store"),
]
@State private var newTodoTitle = ""
// Add bar
HStack {
TextField("New task...", text: $newTodoTitle)
.textFieldStyle(.roundedBorder)
Button {
guard !newTodoTitle.isEmpty else { return }
withAnimation {
todos.insert(TodoItem(title: newTodoTitle), at: 0)
newTodoTitle = ""
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.padding()
Step 3: The List with Toggle and Swipe Delete
List {
ForEach($todos) { $todo in
HStack(spacing: 12) {
Button {
withAnimation { todo.isCompleted.toggle() }
} label: {
Image(systemName: todo.isCompleted
? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(
todo.isCompleted ? .green : .gray)
}
.buttonStyle(.plain)
Text(todo.title)
.strikethrough(todo.isCompleted)
.foregroundStyle(
todo.isCompleted ? .secondary : .primary)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
withAnimation {
todos.removeAll { $0.id == todo.id }
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
How ForEach($todos) Works
Using ForEach($todos) with the dollar sign gives you a Binding<TodoItem> for each element. This means you can mutate todo.isCompleted directly and the change propagates back to the original array. Without the binding, you would need to find the item's index and update it manually.
Tips and Pitfalls
- Insert at index 0 puts new tasks at the top, which feels more natural than appending to the bottom of a long list.
- .buttonStyle(.plain) on the checkmark prevents the entire row from highlighting when the checkbox is tapped.
- guard !newTodoTitle.isEmpty prevents adding blank tasks. In production, also trim whitespace.
- Persistence: Wrap the todos array with
@AppStorage(using Codable encoding) or Core Data for data that survives app restarts.
iOS Version: iOS 15+ (swipeActions requires iOS 15)