Skip to content

Build a SwiftUI Todo App — Full-Stack Project with Persistence

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Build a SwiftUI Todo App. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete Todo app in SwiftUI with CRUD operations, categories, search, sorting, persistence using SwiftData, and a clean MVVM Architecture.

What You'll Build

  • A Todo list with add, edit, complete, and delete
  • Categories and priority levels
  • Search and filter functionality
  • SwiftData persistence
  • MVVM architecture pattern
  • Unit tests for the view model

Why It Matters

The Todo app is the universal beginner project for a reason: it exercises every fundamental iOS development skill — list management, form input, data persistence, navigation, state management, and testing. Mastering this project prepares you to build any data-driven iOS app.

Real-World Use

Every productivity app on the App Store is a more sophisticated Todo app: Things 3, Todoist, Microsoft To Do, Apple Reminders. The patterns you learn here — CRUD operations, filtering, search, data persistence — are the same patterns used in those million-dollar apps.

Learning Path

flowchart LR
  A[Swift Package Manager
Lesson 32] --> B[Project: Todo App
You are here] B --> C[Project: Weather App
Lesson 34] B --> D[Project: Networking Library
Lesson 35] style B fill:#f90,color:#fff

Project Setup

Create a new SwiftUI iOS app in Xcode named "TodoApp". Enable SwiftData in project settings.

Alternative: start from the template and add SwiftData manually.

Data Model

import SwiftData
import Foundation

@Model
class TodoItem {
    var title: String
    var details: String
    var isCompleted: Bool
    var priority: Priority
    var category: Category?
    var createdAt: Date
    var dueDate: Date?

    enum Priority: String, Codable, CaseIterable {
        case low, medium, high

        var order: Int {
            switch self {
            case .low: return 0
            case .medium: return 1
            case .high: return 2
            }
        }
    }

    init(title: String, details: String = "", priority: Priority = .medium,
         category: Category? = nil, dueDate: Date? = nil) {
        self.title = title
        self.details = details
        self.isCompleted = false
        self.priority = priority
        self.category = category
        self.createdAt = Date()
        self.dueDate = dueDate
    }
}

@Model
class Category {
    var name: String
    var colorHex: String
    var iconName: String

    @Relationship(inverse: \TodoItem.category)
    var items: [TodoItem] = []

    init(name: String, colorHex: String = "007AFF", iconName: String = "folder") {
        self.name = name
        self.colorHex = colorHex
        self.iconName = iconName
    }
}

SwiftData's @Model macro generates all persistence code. The @Relationship inverse links categories to their items.

View Model

import SwiftUI
import SwiftData

@MainActor
class TodoViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var selectedPriority: TodoItem.Priority?
    @Published var selectedCategory: Category?
    @Published var sortBy: SortOption = .dateCreated
    @Published var showCompleted = true

    enum SortOption: String, CaseIterable {
        case dateCreated = "Date Created"
        case priority = "Priority"
        case dueDate = "Due Date"
        case title = "Title"
    }

    func fetchPredicate() -> Predicate<TodoItem>? {
        var predicates: [Predicate<TodoItem>] = []

        if !searchText.isEmpty {
            predicates.append(#Predicate { $0.title.localizedStandardContains(searchText) })
        }

        if let priority = selectedPriority {
            predicates.append(#Predicate { $0.priority == priority })
        }

        if let category = selectedCategory {
            predicates.append(#Predicate { $0.category?.name == category.name })
        }

        if !showCompleted {
            predicates.append(#Predicate { !$0.isCompleted })
        }

        guard !predicates.isEmpty else { return nil }
        return #Predicate { item in
            predicates.map { predicate in
                predicate.evaluate(item)
            }.reduce(false) { $0 || $1 }
        }
    }

    func fetchSort() -> [SortDescriptor<TodoItem>] {
        switch sortBy {
        case .dateCreated:
            return [SortDescriptor(\.createdAt, order: .reverse)]
        case .priority:
            return [SortDescriptor(\.priority, order: .reverse), SortDescriptor(\.createdAt)]
        case .dueDate:
            return [SortDescriptor(\.dueDate, order: .forward)]
        case .title:
            return [SortDescriptor(\.title)]
        }
    }

    func toggleComplete(_ item: TodoItem, context: ModelContext) {
        item.isCompleted.toggle()
        try? context.save()
    }

    func delete(_ item: TodoItem, context: ModelContext) {
        context.delete(item)
        try? context.save()
    }

    func addItem(title: String, details: String, priority: TodoItem.Priority,
                 category: Category?, dueDate: Date?, context: ModelContext) {
        let item = TodoItem(title: title, details: details, priority: priority,
                           category: category, dueDate: dueDate)
        context.insert(item)
        try? context.save()
    }
}

Main List View

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var context
    @StateObject private var viewModel = TodoViewModel()
    @Query private var items: [TodoItem]

    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    HStack {
                        Button {
                            viewModel.toggleComplete(item, context: context)
                        } label: {
                            Image(systemName: item.isCompleted
                                  ? "checkmark.circle.fill"
                                  : "circle")
                                .foregroundColor(item.isCompleted ? .green : .gray)
                                .font(.title2)
                        }

                        VStack(alignment: .leading) {
                            Text(item.title)
                                .strikethrough(item.isCompleted)
                                .foregroundColor(item.isCompleted ? .secondary : .primary)
                            if !item.details.isEmpty {
                                Text(item.details)
                                    .font(.caption)
                                    .foregroundColor(.secondary)
                                    .lineLimit(1)
                            }
                            if let dueDate = item.dueDate {
                                Text(dueDate.formatted(date: .abbreviated, time: .omitted))
                                    .font(.caption2)
                                    .foregroundColor(dueDate < Date() ? .red : .secondary)
                            }
                        }

                        Spacer()

                        PriorityBadge(priority: item.priority)
                    }
                    .swipeActions(edge: .trailing) {
                        Button(role: .destructive) {
                            viewModel.delete(item, context: context)
                        } label: {
                            Label("Delete", systemImage: "trash")
                        }
                    }
                }
            }
            .navigationTitle("Tasks")
            .searchable(text: $viewModel.searchText)
            .toolbar {
                ToolbarItem {
                    NavigationLink("Add") {
                        AddTodoView()
                    }
                }
            }
        }
    }
}

struct PriorityBadge: View {
    let priority: TodoItem.Priority

    var body: some View {
        Text(priority.rawValue.uppercased())
            .font(.caption2)
            .fontWeight(.bold)
            .padding(.horizontal, 6)
            .padding(.vertical, 2)
            .background(priorityColor.opacity(0.2))
            .foregroundColor(priorityColor)
            .cornerRadius(4)
    }

    var priorityColor: Color {
        switch priority {
        case .low: return .green
        case .medium: return .orange
        case .high: return .red
        }
    }
}

Add Todo View

import SwiftUI
import SwiftData

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

    @State private var title = ""
    @State private var details = ""
    @State private var priority: TodoItem.Priority = .medium
    @State private var hasDueDate = false
    @State private var dueDate = Date()
    @State private var selectedCategory: Category?

    @Query private var categories: [Category]

    var body: some View {
        Form {
            Section("Task") {
                TextField("Title", text: $title)
                TextField("Details", text: $details, axis: .vertical)
                    .lineLimit(3)

                Picker("Priority", selection: $priority) {
                    ForEach(TodoItem.Priority.allCases, id: \.self) { p in
                        Text(p.rawValue.capitalized).tag(p)
                    }
                }

                Toggle("Set Due Date", isOn: $hasDueDate)
                if hasDueDate {
                    DatePicker("Due", selection: $dueDate, displayedComponents: .date)
                }
            }

            if !categories.isEmpty {
                Section("Category") {
                    Picker("Category", selection: $selectedCategory) {
                        Text("None").tag(nil as Category?)
                        ForEach(categories) { cat in
                            HStack {
                                Image(systemName: cat.iconName)
                                Text(cat.name)
                            }.tag(cat as Category?)
                        }
                    }
                }
            }

            Section {
                Button("Add Task") {
                    let vm = TodoViewModel()
                    vm.addItem(title: title, details: details, priority: priority,
                              category: selectedCategory, dueDate: hasDueDate ? dueDate : nil,
                              context: context)
                    dismiss()
                }
                .disabled(title.isEmpty)
                .frame(maxWidth: .infinity)
            }
        }
        .navigationTitle("New Task")
    }
}

Filter and Sort Controls

struct FilterSortView: View {
    @StateObject var viewModel: TodoViewModel

    var body: some View {
        Form {
            Section("Filter") {
                Toggle("Show Completed", isOn: $viewModel.showCompleted)

                Picker("Priority", selection: $viewModel.selectedPriority) {
                    Text("All").tag(nil as TodoItem.Priority?)
                    ForEach(TodoItem.Priority.allCases, id: \.self) { p in
                        Text(p.rawValue.capitalized).tag(p as TodoItem.Priority?)
                    }
                }
            }

            Section("Sort") {
                Picker("Sort By", selection: $viewModel.sortBy) {
                    ForEach(TodoViewModel.SortOption.allCases, id: \.self) { option in
                        Text(option.rawValue).tag(option)
                    }
                }
            }
        }
        .navigationTitle("Filter & Sort")
    }
}

Testing

import XCTest
@testable import TodoApp

class TodoViewModelTests: XCTestCase {
    func testToggleCompleted() {
        let item = TodoItem(title: "Test")
        XCTAssertFalse(item.isCompleted)
        item.isCompleted.toggle()
        XCTAssertTrue(item.isCompleted)
    }

    func testPriorityOrder() {
        XCTAssertLessThan(TodoItem.Priority.low.order,
                         TodoItem.Priority.high.order)
    }

    func testItemCreation() {
        let item = TodoItem(title: "New Task", priority: .high)
        XCTAssertEqual(item.title, "New Task")
        XCTAssertEqual(item.priority, .high)
        XCTAssertFalse(item.isCompleted)
    }
}

Key Takeaways

  • SwiftData eliminates boilerplate persistence code
  • MVVM keeps logic separate from views
  • Swipe actions provide intuitive interaction
  • Search and filter create a production-quality experience
  • Testing the view model covers business logic without UI dependencies

Common Mistakes

  1. Not using SwiftData @Model correctly: Every persisted type needs @Model. Relationships need @Relationship with proper inverse.

  2. Mixing view logic and model logic: Keep data operations in the view model, not in views.

  3. Forgetting to save context: Changes are in-memory until try context.save() is called.

  4. Not handling empty states: Show a helpful message when the list is empty.

  5. Overcomplicating filters: Start simple with a few filters and add complexity only as needed.

Practice Questions

  1. How does SwiftData's @Model macro differ from Core Data's NSManagedObject?
  2. Why is the view model marked @MainActor?
  3. How would you add a "recurring task" feature?
  4. What is the purpose of @Relationship(inverse:)?
  5. Challenge: Add a "Statistics" view showing total tasks, completed today, by priority breakdown, and average completion time.

FAQ

Should I use SwiftData or Core Data for a Todo app?

SwiftData is simpler and works great for this app. Use Core Data if you need advanced features like migration presets or spotlight integration not yet supported by SwiftData.

How do I handle undo/redo?

SwiftData does not support undo/redo natively yet. Use an undo manager pattern: store snapshots of state before mutations.

{{< faq "Can I sync todos across devices?" "Yes. Use iCloud sync with NSPersistentCloudKitContainer for Core Data. SwiftData iCloud sync is available in iOS 17+.", >}}
How do I add notifications for due dates?

Use UNUserNotificationCenter. Schedule a local notification when a task is created with a due date, and cancel it when completed.

How do I add drag-and-drop reordering?

Add .onMove modifier to ForEach and use context.insert/delete to update the order property on each item.

What's Next

Build a Project: Weather App that fetches live API data, or create a reusable Project: Networking Library as an SPM package.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro