```html How to Build a To-Do List App in SwiftUI (2026)

How to Build a To-Do List App in SwiftUI

A To-Do List app lets users capture tasks with priorities and due dates, then get nudged by local notifications when deadlines arrive. It's the ideal first iOS project: small enough to ship in a weekend, real enough to keep in your portfolio.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 11, 2026

Prerequisites

Architecture overview

The app uses SwiftData as its persistence layer — one @Model class (TodoItem) owned by a ModelContainer injected at the app entry point. Views read and write tasks through SwiftData's @Query macro, eliminating a separate view model for this complexity level. UNUserNotificationCenter handles due-date reminders entirely on-device; no server needed. A lightweight NotificationService struct keeps scheduling logic out of the views.

TodoListApp/
├── TodoListApp.swift          # @main, ModelContainer setup
├── Models/
│   └── TodoItem.swift         # @Model: title, priority, dueDate, isCompleted
├── Views/
│   ├── ContentView.swift      # List + toolbar
│   ├── TaskRowView.swift      # Single row with priority badge
│   └── AddTaskSheet.swift     # Form: title, priority picker, DatePicker
├── Services/
│   └── NotificationService.swift  # UNUserNotificationCenter helpers
├── Resources/
│   └── PrivacyInfo.xcprivacy  # Required for App Store
└── Assets.xcassets
        

Step-by-step

1. Create the Xcode project

Open Xcode, choose File › New › Project, pick the iOS App template, set Interface to SwiftUI and Storage to SwiftData. Name it TodoListApp. Xcode scaffolds the ModelContainer wiring for you — delete the placeholder Item model before writing your own.

// TodoListApp.swift
import SwiftUI
import SwiftData

@main
struct TodoListApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: TodoItem.self)
    }
}

2. Define the SwiftData model

The TodoItem model stores everything a task needs: a title, an enum-backed priority, an optional due date, and a completion flag. Marking the class @Model tells SwiftData to persist and observe it automatically — no manual Codable conformance required.

// Models/TodoItem.swift
import SwiftData
import Foundation

enum Priority: Int, Codable, CaseIterable, Identifiable {
    case low = 0, medium = 1, high = 2

    var id: Int { rawValue }

    var label: String {
        switch self {
        case .low:    return "Low"
        case .medium: return "Medium"
        case .high:   return "High"
        }
    }

    var color: String {
        switch self {
        case .low:    return "priorityLow"    // asset catalog color
        case .medium: return "priorityMedium"
        case .high:   return "priorityHigh"
        }
    }
}

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

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

3. Build the main task list UI

ContentView uses SwiftData's @Query to fetch tasks sorted by priority (high first), then renders them in a List. Swipe actions provide quick complete and delete without entering an edit mode — a pattern users expect on iOS.

// Views/ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: [
        SortDescriptor(\TodoItem.priority, order: .reverse),
        SortDescriptor(\TodoItem.createdAt)
    ])
    private var tasks: [TodoItem]

    @State private var showingAddSheet = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(tasks) { task in
                    TaskRowView(task: task)
                        .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                            Button(role: .destructive) {
                                context.delete(task)
                                NotificationService.cancel(for: task)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        }
                        .swipeActions(edge: .leading) {
                            Button {
                                task.isCompleted.toggle()
                            } label: {
                                Label(
                                    task.isCompleted ? "Reopen" : "Done",
                                    systemImage: task.isCompleted
                                        ? "arrow.uturn.backward" : "checkmark"
                                )
                            }
                            .tint(task.isCompleted ? .orange : .green)
                        }
                }
            }
            .navigationTitle("My Tasks")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showingAddSheet = true
                    } label: {
                        Label("Add Task", systemImage: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddTaskSheet()
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: TodoItem.self, inMemory: true)
}

4. Implement priorities and due dates

AddTaskSheet combines a TextField, a segmented Picker for priority, and a DatePicker toggled by a toggle. On save it inserts the new TodoItem into the SwiftData context and schedules a notification if a due date was chosen.

// Views/AddTaskSheet.swift
import SwiftUI
import SwiftData

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

    @State private var title = ""
    @State private var priority: Priority = .medium
    @State private var hasDueDate = false
    @State private var dueDate = Date.now.addingTimeInterval(3600)

    var body: some View {
        NavigationStack {
            Form {
                Section("Task") {
                    TextField("What needs to be done?", text: $title)
                }

                Section("Priority") {
                    Picker("Priority", selection: $priority) {
                        ForEach(Priority.allCases) { p in
                            Text(p.label).tag(p)
                        }
                    }
                    .pickerStyle(.segmented)
                }

                Section("Due Date") {
                    Toggle("Set a reminder", isOn: $hasDueDate)
                    if hasDueDate {
                        DatePicker(
                            "Due",
                            selection: $dueDate,
                            in: Date.now...,
                            displayedComponents: [.date, .hourAndMinute]
                        )
                        .datePickerStyle(.graphical)
                    }
                }
            }
            .navigationTitle("New Task")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                        .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
        }
    }

    private func save() {
        let item = TodoItem(
            title: title.trimmingCharacters(in: .whitespaces),
            priority: priority,
            dueDate: hasDueDate ? dueDate : nil
        )
        context.insert(item)
        if hasDueDate {
            NotificationService.schedule(for: item)
        }
        dismiss()
    }
}

// Views/TaskRowView.swift
import SwiftUI

struct TaskRowView: View {
    let task: TodoItem

    private static let dateFormatter: DateFormatter = {
        let f = DateFormatter()
        f.dateStyle = .medium
        f.timeStyle = .short
        return f
    }()

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                .foregroundStyle(task.isCompleted ? .green : .secondary)
                .font(.title3)

            VStack(alignment: .leading, spacing: 4) {
                Text(task.title)
                    .strikethrough(task.isCompleted)
                    .foregroundStyle(task.isCompleted ? .secondary : .primary)

                if let due = task.dueDate {
                    Label(
                        Self.dateFormatter.string(from: due),
                        systemImage: "calendar"
                    )
                    .font(.caption)
                    .foregroundStyle(due < .now && !task.isCompleted ? .red : .secondary)
                }
            }

            Spacer()

            Text(task.priority.label)
                .font(.caption2.weight(.semibold))
                .padding(.horizontal, 8)
                .padding(.vertical, 3)
                .background(Color(task.priority.color).opacity(0.2))
                .foregroundStyle(Color(task.priority.color))
                .clipShape(Capsule())
        }
        .padding(.vertical, 4)
    }
}

#Preview {
    let item = TodoItem(title: "Buy groceries", priority: .high, dueDate: .now.addingTimeInterval(7200))
    TaskRowView(task: item)
        .padding()
}

5. Schedule notifications and add Privacy Manifest

Request notification authorization once at app launch, then schedule a UNCalendarNotificationTrigger per task. The Privacy Manifest (PrivacyInfo.xcprivacy) is a hard App Store requirement — submissions without it are rejected at upload time.

// Services/NotificationService.swift
import UserNotifications
import Foundation

struct NotificationService {

    static func requestAuthorization() {
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .sound, .badge]
        ) { _, _ in }
    }

    static func schedule(for item: TodoItem) {
        guard let due = item.dueDate, due > .now else { return }

        let content = UNMutableNotificationContent()
        content.title = "Task Due"
        content.body = item.title
        content.sound = .default
        content.interruptionLevel = item.priority == .high ? .timeSensitive : .active

        var comps = Calendar.current.dateComponents(
            [.year, .month, .day, .hour, .minute],
            from: due
        )
        comps.second = 0

        let trigger = UNCalendarNotificationTrigger(
            dateMatching: comps,
            repeats: false
        )
        let request = UNNotificationRequest(
            identifier: item.persistentModelID.hashValue.description,
            content: content,
            trigger: trigger
        )
        UNUserNotificationCenter.current().add(request)
    }

    static func cancel(for item: TodoItem) {
        let id = item.persistentModelID.hashValue.description
        UNUserNotificationCenter.current().removePendingNotificationRequests(
            withIdentifiers: [id]
        )
    }
}

// In TodoListApp.swift — call at startup:
// NotificationService.requestAuthorization()

Add PrivacyInfo.xcprivacy to your project root. In Xcode: File › New › File from Template › search "Privacy". This app does not collect data, so the manifest is straightforward:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array/>
</dict>
</plist>

Common pitfalls

Adding monetization: Freemium

The freemium model works well for a To-Do app: let free users create up to 10 active tasks, then unlock unlimited tasks and a "Priority Widgets" home-screen widget pack via a one-time in-app purchase using StoreKit 2. Define your product in App Store Connect (type: Non-Consumable), then use Product.products(for:) to fetch the listing and product.purchase() to initiate the transaction. Observe Transaction.updates in a background Task at app launch to handle restored purchases and subscription renewals. Gate the premium features behind a @AppStorage("isPremium") flag that you set after a verified Transaction — never trust client-side state alone; always verify via Transaction.currentEntitlement(for:).

Shipping this faster with Soarias

Soarias automates the parts of shipping that aren't writing product code. For a To-Do app it handles the SwiftData project scaffold, generates the PrivacyInfo.xcprivacy manifest, configures fastlane with match for code signing, takes App Store screenshots on your simulator fleet, fills in the required ASC metadata fields (description, keywords, age rating), and submits the binary — all from a single command once you've connected your App Store Connect API key.

At beginner complexity, the manual path typically eats one of your two weekends just on signing, screenshot sizing, and ASC form-filling before your first TestFlight build lands. Soarias compresses that overhead to under an hour, so your weekend goes toward polishing swipe interactions and notification copy instead of fighting provisioning profiles.

Related guides

FAQ

Last reviewed: 2026-05-11 by the Soarias team.

```