Build a SwiftUI Todo App — Full-Stack Project with Persistence
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
Not using SwiftData @Model correctly: Every persisted type needs
@Model. Relationships need@Relationshipwith proper inverse.Mixing view logic and model logic: Keep data operations in the view model, not in views.
Forgetting to save context: Changes are in-memory until
try context.save()is called.Not handling empty states: Show a helpful message when the list is empty.
Overcomplicating filters: Start simple with a few filters and add complexity only as needed.
Practice Questions
- How does SwiftData's @Model macro differ from Core Data's NSManagedObject?
- Why is the view model marked @MainActor?
- How would you add a "recurring task" feature?
- What is the purpose of
@Relationship(inverse:)? - Challenge: Add a "Statistics" view showing total tasks, completed today, by priority breakdown, and average completion time.
FAQ
{{< faq "Can I sync todos across devices?" "Yes. Use iCloud sync with NSPersistentCloudKitContainer for Core Data. SwiftData iCloud sync is available in iOS 17+.", >}}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