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)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.