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.
Prerequisites
- Mac running macOS Sequoia or later with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI familiarity (variables, structs, @State)
- A physical iPhone or iPad for testing local notifications — the simulator supports them but delivery timing can differ
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
-
Forgetting the Privacy Manifest → immediate App Store rejection.
Since Xcode 15.3, any app that omits
PrivacyInfo.xcprivacyis rejected at upload by Transporter before it even reaches review. Add the file even if you collect no data — the empty manifest is still required. -
Scheduling notifications without checking authorization status.
Calling
UNUserNotificationCenter.add(_:)silently succeeds even when permission is denied — the notification just never fires. Always callgetNotificationSettingsbefore scheduling, and prompt the user to re-enable in Settings if needed. -
Using
@ObservableObjecton a SwiftData@Modelclass.@Modelimplicitly uses the@Observablemacro under the hood. Adding@ObservableObjectcreates a conformance conflict. Stick to@Modelalone and use@Environment(\.modelContext)in views. -
App Store review: notification permission flow.
Apple's HIG guidelines require apps to explain why they want notification access before triggering the system prompt. Add a brief explanation sheet before calling
requestAuthorization— reviewers flag apps that show the permission dialog with no context. -
Past-due dates crashing the DatePicker range.
If you load an existing task and pass its (past) due date as the default value into a
DatePickerwith anin: Date.now...range, the picker clamps the display but emits a confusing warning. Use a separate editing state variable initialized tomax(existing, Date.now).
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
- Coming soon More SwiftUI guides on their way
FAQ
-
Does this work on iOS 16?
SwiftData and the
#Previewmacro both require iOS 17. If you need iOS 16 support, replace SwiftData with Core Data (or a plainCodable+FileManagerapproach) and swap#Previewback toPreviewProvider. That said, iOS 17 adoption is above 90% as of 2026, so targeting iOS 17+ is the pragmatic choice for new apps. - Do I need a paid Apple Developer account to test? No — you can run the app on a personal device with a free Apple ID via Xcode's automatic signing. However, local notifications, StoreKit sandbox testing, and TestFlight all require an active $99/year Apple Developer Program membership.
-
How do I add this to the App Store?
Archive the app in Xcode (Product › Archive), upload via the Organizer window or
altool/notarytool, then complete the App Store Connect listing: screenshots (6.7-inch required, 6.5-inch and iPad optional), description, keywords, privacy details, and age rating. Submit for review — first-time apps typically take 1–3 business days. -
I'm a beginner — should I use SwiftData or Core Data?
Use SwiftData. It was designed for exactly this: simple, local-first apps where you don't need multi-store migrations or NSFetchedResultsController. The
@Model/@Querypattern maps directly onto SwiftUI's mental model, and you'll write roughly 60% less boilerplate than an equivalent Core Data stack. Migrate to Core Data only if you need features SwiftData doesn't yet support (e.g., custom persistent history tracking or CloudKit sync beyond the basic.cloudKitDatabaseoption).
Last reviewed: 2026-05-11 by the Soarias team.