Build a Task Manager App with SwiftUI and SwiftData: Full CRUD and Persistence
Every great Pixar film starts with a single task on a sticky note — “What if toys came alive?” — and grows into hundreds of interlocking production steps tracked across story, animation, lighting, and sound. By the end of this tutorial, your iPhone will be the production board for all of it.
In this tutorial, you’ll build Pixar Production Tracker — a full-featured task manager where each task represents a
step in making a Pixar film, organized by production category, prioritized by urgency, and persisted on-device with
SwiftData. Along the way, you’ll learn how to model relational data with @Model and @Relationship, drive reactive
lists with @Query and #Predicate, and implement swipe actions, search, and inline editing with @Bindable.
Prerequisites
- Xcode 16+ with iOS 18 deployment target
- Familiarity with SwiftData
- Familiarity with SwiftUI Lists and Navigation
- Familiarity with SwiftUI Forms and User Input
Contents
- Getting Started
- Step 1: Defining the Data Models
- Step 2: Building the Task List
- Step 3: Adding the Create Task Form
- Step 4: Category Management and Filtering
- Step 5: Implementing Search
- Step 6: Priority Badges and Due Date Highlighting
- Step 7: Swipe to Complete
- Step 8: Task Detail and Edit View
- Where to Go From Here?
Getting Started
Open Xcode and create a new project:
- Choose File > New > Project, then select the App template under iOS.
- Set the Product Name to
PixarProductionTracker. - Set the Interface to SwiftUI and the Language to Swift.
- Set your Minimum Deployments to iOS 18.0 in the project settings. SwiftData was introduced at iOS 17, and the
#Predicatemacro we use for filtering requires it. We target iOS 18 to take advantage of the latest SwiftUI and SwiftData improvements.
Note: Because the entire app targets iOS 18+, you won’t need any conditional availability guards in your code.
Once the project is open, take stock of what Xcode generated: a ContentView.swift and the
PixarProductionTrackerApp.swift entry point. You’ll be adding new files alongside these.
Adding the ModelContainer
The first thing the app needs is a
ModelContainer — SwiftData’s equivalent of a
database connection. Open PixarProductionTrackerApp.swift and replace its contents:
import SwiftUI
import SwiftData
@main
struct PixarProductionTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [PixarTask.self, TaskCategory.self]) // ← registers both models
}
}
The .modelContainer(for:) modifier tells SwiftData which model types to manage and creates the underlying SQLite store
automatically. You’ll define PixarTask and TaskCategory in the next step — Xcode will show errors until then, which
is expected.
Apple Docs:
ModelContainer— SwiftData
Step 1: Defining the Data Models
SwiftData models are Swift classes annotated with
@Model. The macro generates all the persistence
infrastructure — change tracking, relationship management, and SQLite schema migrations — so you write plain Swift and
get a full database for free.
The app has two models: TaskCategory (department name like “Story” or “Animation”) and PixarTask (the individual
production step). Start with the category because PixarTask will reference it.
Creating TaskCategory
Create a new Swift file at Models/TaskCategory.swift. If the Models group doesn’t exist, create it: right-click in
the navigator, choose New Group, and name it Models.
import Foundation
import SwiftData
@Model
final class TaskCategory {
var name: String
var colorHex: String // e.g. "#FF6B6B" — stored as a string for Codable compatibility
// The inverse side of the relationship: all tasks in this category
@Relationship(deleteRule: .nullify, inverse: \PixarTask.category)
var tasks: [PixarTask] = []
init(name: String, colorHex: String) {
self.name = name
self.colorHex = colorHex
}
}
The @Relationship macro on tasks uses a .nullify delete rule: if you delete a category, its tasks remain in the
database but have their category reference set to nil. This keeps production tasks from disappearing just because a
department was renamed.
Apple Docs:
@Relationship— SwiftData
Creating PixarTask
Create Models/PixarTask.swift:
import Foundation
import SwiftData
// Priority levels for a production task
enum TaskPriority: String, Codable, CaseIterable {
case low = "Low"
case medium = "Medium"
case high = "High"
case critical = "Critical"
var sortOrder: Int {
switch self {
case .critical: return 0
case .high: return 1
case .medium: return 2
case .low: return 3
}
}
}
@Model
final class PixarTask {
var title: String
var notes: String
var priority: TaskPriority
var prioritySortOrder: Int // Stored copy for SwiftData sort descriptors
var dueDate: Date?
var isCompleted: Bool
var createdAt: Date
// The owning side of the relationship: which department this task belongs to
var category: TaskCategory?
init(
title: String,
notes: String = "",
priority: TaskPriority = .medium,
dueDate: Date? = nil,
category: TaskCategory? = nil
) {
self.title = title
self.notes = notes
self.priority = priority
self.prioritySortOrder = priority.sortOrder
self.dueDate = dueDate
self.isCompleted = false
self.createdAt = Date()
self.category = category
}
}
A few design decisions worth noting:
TaskPriorityconforms toCodableso SwiftData can serialize the enum’s rawStringvalue into the database column.prioritySortOrderis a storedIntthat mirrorspriority.sortOrder. SwiftData sort descriptors require persistent stored properties — computed properties cannot be used as sort key paths. Setting this value ininitensures it stays in sync with the priority.dueDateis optional (Date?) because not every production step has a hard deadline.createdAtis set once ininitand never mutated — it gives you a stable secondary sort key when two tasks have the same priority.- The
categoryproperty is the “owning” side of the relationship. When SwiftData seesvar category: TaskCategory?paired with the@Relationship(inverse:)onTaskCategory.tasks, it wires them together automatically.
Inserting Sample Data
Real apps should seed sample data on first launch. Create Models/SampleData.swift:
import Foundation
import SwiftData
@MainActor
enum SampleData {
static func insert(into context: ModelContext) {
// Don't seed if data already exists
let descriptor = FetchDescriptor<TaskCategory>()
guard (try? context.fetchCount(descriptor)) == 0 else { return }
// Production departments
let story = TaskCategory(name: "Story", colorHex: "#FF6B6B")
let animation = TaskCategory(name: "Animation", colorHex: "#4ECDC4")
let lighting = TaskCategory(name: "Lighting", colorHex: "#FFE66D")
let sound = TaskCategory(name: "Sound", colorHex: "#A8E6CF")
let production = TaskCategory(name: "Production", colorHex: "#C3A6FF")
// Insert categories first so relationships resolve
[story, animation, lighting, sound, production].forEach { context.insert($0) }
// Sample tasks spanning an imaginary "Toy Story 5" production
let tasks: [PixarTask] = [
PixarTask(title: "Write Act 2 outline",
notes: "Woody discovers the toy museum subplot.",
priority: .critical,
dueDate: Calendar.current.date(byAdding: .day, value: -2, to: Date()),
category: story),
PixarTask(title: "Storyboard the inciting incident",
notes: "Andy's college flashback sequence.",
priority: .high,
dueDate: Calendar.current.date(byAdding: .day, value: 3, to: Date()),
category: story),
PixarTask(title: "Rig Buzz Lightyear's wings",
notes: "Needs new feather simulation for the opening scene.",
priority: .high,
dueDate: Calendar.current.date(byAdding: .day, value: 5, to: Date()),
category: animation),
PixarTask(title: "Animate crowd scene in toy store",
notes: "At least 200 unique background toys.",
priority: .medium,
category: animation),
PixarTask(title: "Light the bedroom sequence",
notes: "Warm sunset coming through the window.",
priority: .medium,
dueDate: Calendar.current.date(byAdding: .day, value: 10, to: Date()),
category: lighting),
PixarTask(title: "Compose Woody's theme variation",
notes: "Needs a melancholy version for the farewell scene.",
priority: .high,
dueDate: Calendar.current.date(byAdding: .day, value: 7, to: Date()),
category: sound),
PixarTask(title: "Record foley for Pizza Planet truck",
notes: "Engine sounds, door creaks.",
priority: .low,
category: sound),
PixarTask(title: "Lock final production budget",
priority: .critical,
dueDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()),
category: production),
]
tasks.forEach { context.insert($0) }
}
}
Notice that the first task has a dueDate two days in the past — this is intentional. You’ll use it in Step 6 to
test overdue highlighting.
Now call SampleData.insert on app launch. Open PixarProductionTrackerApp.swift and update it:
@main
struct PixarProductionTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
// Seed is a no-op if data already exists
}
}
.modelContainer(for: [PixarTask.self, TaskCategory.self],
isAutosaveEnabled: true) { result in
if case .success(let container) = result {
SampleData.insert(into: container.mainContext)
}
}
}
}
The trailing closure of .modelContainer(for:) fires after the container finishes initializing, giving you a safe
context to insert sample data.
Checkpoint: Build the project. It should compile without errors. You won’t see any UI change yet — you’ll build the list view in the next step. If Xcode reports an error about missing types, verify that
PixarTask.swift,TaskCategory.swift, andSampleData.swiftare all members of thePixarProductionTrackertarget (check the File Inspector’s Target Membership).
Step 2: Building the Task List
With the models in place, you can build the main list view. Create Views/TaskListView.swift:
import SwiftUI
import SwiftData
struct TaskListView: View {
// @Query drives the list reactively — any database change re-renders the view
@Query(sort: [
SortDescriptor(\PixarTask.prioritySortOrder),
SortDescriptor(\PixarTask.createdAt)
]) private var tasks: [PixarTask]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
ForEach(tasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: deleteTasks)
}
.navigationTitle("Production Board")
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}
}
}
@Query is SwiftData’s reactive fetch mechanism. It runs a
live SQL query against the model container and re-renders the view whenever the underlying data changes — inserts,
updates, and deletes all trigger a refresh automatically. The SortDescriptor array provides a stable sort: tasks are
ordered first by priority (Critical before Low), then by creation date as a tiebreaker.
Apple Docs:
@Query— SwiftData
Building the Task Row
Create Views/TaskRowView.swift for the individual row:
import SwiftUI
struct TaskRowView: View {
let task: PixarTask
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Completion indicator
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, color: .secondary)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
if let category = task.category {
Text(category.name)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
PriorityBadgeView(priority: task.priority)
}
.padding(.vertical, 4)
}
}
You’ll build PriorityBadgeView in Step 6 — for now, add a placeholder in a new file Views/PriorityBadgeView.swift:
import SwiftUI
struct PriorityBadgeView: View {
let priority: TaskPriority
var body: some View {
Text(priority.rawValue)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
}
Wiring the Navigation Stack
Replace the contents of ContentView.swift:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationStack {
TaskListView()
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Task", systemImage: "plus") {
// Step 3: present create form
}
}
}
}
}
}
Checkpoint: Build and run. You should see a list titled “Production Board” populated with the eight Pixar production tasks seeded in Step 1. The list is sorted by priority — “Lock final production budget” (Critical) and “Write Act 2 outline” (Critical) appear at the top. Swipe left on any row to reveal the Delete action; tapping it removes the task from the database immediately. If the list is empty, check that
SampleData.insertis being called inPixarProductionTrackerApp.swift.
Step 3: Adding the Create Task Form
Users need to add new production steps. You’ll present a sheet with a form that collects all the task fields. Create
Views/CreateTaskView.swift:
import SwiftUI
import SwiftData
struct CreateTaskView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
// Fetch available categories to populate the picker
@Query(sort: \TaskCategory.name) private var categories: [TaskCategory]
// Local state for the form fields
@State private var title: String = ""
@State private var notes: String = ""
@State private var priority: TaskPriority = .medium
@State private var dueDate: Date = Date()
@State private var hasDueDate: Bool = false
@State private var selectedCategory: TaskCategory?
var body: some View {
NavigationStack {
Form {
Section("Task Details") {
TextField("Title", text: $title)
.submitLabel(.done)
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section("Category & Priority") {
Picker("Category", selection: $selectedCategory) {
Text("None").tag(Optional<TaskCategory>.none)
ForEach(categories) { category in
Text(category.name).tag(Optional(category))
}
}
Picker("Priority", selection: $priority) {
ForEach(TaskPriority.allCases, id: \.self) { level in
Text(level.rawValue).tag(level)
}
}
.pickerStyle(.segmented)
}
Section("Due Date") {
Toggle("Has Due Date", isOn: $hasDueDate)
if hasDueDate {
DatePicker(
"Due",
selection: $dueDate,
displayedComponents: [.date]
)
}
}
}
.navigationTitle("New Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") { saveTask() }
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func saveTask() {
let newTask = PixarTask(
title: title.trimmingCharacters(in: .whitespaces),
notes: notes,
priority: priority,
dueDate: hasDueDate ? dueDate : nil,
category: selectedCategory
)
modelContext.insert(newTask)
dismiss()
}
}
There are a few SwiftUI form patterns here worth understanding:
TextField("Notes", ..., axis: .vertical)withlineLimit(3...6)creates an auto-expanding text field — it starts at three lines and grows up to six as the user types. This is a SwiftUI 4+ pattern.- The
PickerforselectedCategoryusesOptional<TaskCategory>as the selection type. This is necessary because the category is optional — you need a.nonetag to represent “no category selected.” - The “Add” button is disabled until
titleis non-empty, preventing empty tasks from entering the database.
Presenting the Sheet
Go back to ContentView.swift and wire up the sheet presentation:
import SwiftUI
struct ContentView: View {
@State private var isShowingCreateForm = false
var body: some View {
NavigationStack {
TaskListView()
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Task", systemImage: "plus") {
isShowingCreateForm = true
}
}
}
.sheet(isPresented: $isShowingCreateForm) {
CreateTaskView()
}
}
}
}
Checkpoint: Build and run. Tap the + button in the toolbar. A sheet slides up showing the form with “Task Details,” “Category & Priority,” and “Due Date” sections. Fill in a title like “Animate Coco’s guitar sequence,” set the priority to High, pick “Animation” from the category picker, and tap Add. The sheet dismisses and the new task appears in the list immediately, sorted into the correct position by priority. The Add button should remain grayed out until you type at least one character in the title field.
Step 4: Category Management and Filtering
Right now all tasks are shown in one flat list. The Pixar production board needs to filter by department. You’ll add a
sidebar-style category list and use #Predicate to filter the @Query results.
The Category List
Create Views/CategorySidebarView.swift:
import SwiftUI
import SwiftData
struct CategorySidebarView: View {
@Query(sort: \TaskCategory.name) private var categories: [TaskCategory]
@Environment(\.modelContext) private var modelContext
// Binding so ContentView knows which category is selected
@Binding var selectedCategory: TaskCategory?
var body: some View {
List(selection: $selectedCategory) {
// "All Tasks" row uses nil to mean "no filter"
Label("All Tasks", systemImage: "tray.full")
.tag(Optional<TaskCategory>.none)
Section("Departments") {
ForEach(categories) { category in
Label(category.name, systemImage: "folder")
.tag(Optional(category))
}
.onDelete(perform: deleteCategories)
}
}
.navigationTitle("Departments")
}
private func deleteCategories(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(categories[index])
}
}
}
Filtering with #Predicate
#Predicate is a Swift macro that compiles your filter
expression into a type-safe SQL WHERE clause at build time — no string-based predicates that silently break at
runtime.
Update TaskListView.swift to accept an optional category filter:
import SwiftUI
import SwiftData
struct TaskListView: View {
// The selected category drives the predicate; nil means "show all"
var selectedCategory: TaskCategory?
@Environment(\.modelContext) private var modelContext
// @Query is initialized with a dynamic predicate in the init
@Query private var tasks: [PixarTask]
init(selectedCategory: TaskCategory?) {
self.selectedCategory = selectedCategory
// Build a predicate based on the selected category
let predicate: Predicate<PixarTask>
if let category = selectedCategory {
predicate = #Predicate<PixarTask> { task in
task.category?.name == category.name
}
} else {
predicate = #Predicate<PixarTask> { _ in true }
}
_tasks = Query(
filter: predicate,
sort: [
SortDescriptor(\PixarTask.isCompleted),
SortDescriptor(\PixarTask.prioritySortOrder),
SortDescriptor(\PixarTask.createdAt)
]
)
}
var body: some View {
List {
ForEach(tasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: deleteTasks)
}
.navigationTitle(selectedCategory?.name ?? "All Tasks")
.overlay {
if tasks.isEmpty {
ContentUnavailableView(
"No Tasks",
systemImage: "checkmark.circle",
description: Text("This department has no production tasks yet.")
)
}
}
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}
}
}
A critical detail: @Query with a dynamic predicate must be initialized inside init, using the _tasks property
wrapper storage syntax. You cannot change the predicate of an existing @Query instance after initialization —
SwiftData compiles the query at view creation time. Reinitializing the query in init each time the view body is
recreated with a new selectedCategory is the standard pattern.
Apple Docs:
#Predicate— SwiftData
Integrating the Sidebar into ContentView
SwiftUI’s NavigationSplitView gives you a
master-detail sidebar layout for free. Update ContentView.swift:
import SwiftUI
import SwiftData
struct ContentView: View {
@State private var selectedCategory: TaskCategory?
@State private var isShowingCreateForm = false
var body: some View {
NavigationSplitView {
CategorySidebarView(selectedCategory: $selectedCategory)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Task", systemImage: "plus") {
isShowingCreateForm = true
}
}
}
} detail: {
NavigationStack {
TaskListView(selectedCategory: selectedCategory)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Task", systemImage: "plus") {
isShowingCreateForm = true
}
}
}
}
}
.sheet(isPresented: $isShowingCreateForm) {
CreateTaskView()
}
}
}
Checkpoint: Build and run. The app now has a two-column layout on iPad and a sidebar-first layout on iPhone. Tap “Animation” in the sidebar — the detail pane filters to show only animation tasks like “Rig Buzz Lightyear’s wings.” Tap “All Tasks” and all eight tasks reappear. Delete a category by swiping left in the sidebar — its tasks remain in the database with their category set to nil (the
.nullifydelete rule from Step 1).
Step 5: Implementing Search
Production trackers live and die by their search. Add a search bar that filters tasks by title and notes. SwiftUI’s
.searchable modifier
attaches a search field to a navigation view and provides a @State-compatible binding to the query string.
The trick is combining the search text predicate with the category predicate. Update TaskListView.swift:
import SwiftUI
import SwiftData
struct TaskListView: View {
var selectedCategory: TaskCategory?
@Environment(\.modelContext) private var modelContext
@Environment(\.isSearching) private var isSearching // ← provided by .searchable
@State private var searchText: String = ""
@Query private var tasks: [PixarTask]
init(selectedCategory: TaskCategory?) {
self.selectedCategory = selectedCategory
// Predicate is built from the category alone at init time.
// Search is applied as a computed filter in the body (see filteredTasks).
let predicate: Predicate<PixarTask>
if let category = selectedCategory {
predicate = #Predicate<PixarTask> { task in
task.category?.name == category.name
}
} else {
predicate = #Predicate<PixarTask> { _ in true }
}
_tasks = Query(
filter: predicate,
sort: [
SortDescriptor(\PixarTask.isCompleted),
SortDescriptor(\PixarTask.prioritySortOrder),
SortDescriptor(\PixarTask.createdAt)
]
)
}
// Secondary in-memory filter for search text
private var filteredTasks: [PixarTask] {
guard !searchText.isEmpty else { return tasks }
return tasks.filter { task in
task.title.localizedCaseInsensitiveContains(searchText) ||
task.notes.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
List {
ForEach(filteredTasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: deleteTasks)
}
.navigationTitle(selectedCategory?.name ?? "All Tasks")
.searchable(text: $searchText, prompt: "Search tasks")
.overlay {
if filteredTasks.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}
}
}
Why use an in-memory filter for search rather than a #Predicate? When search text is combined with a dynamic category
predicate, #Predicate requires the search string to be captured in the predicate closure — which means rebuilding the
@Query every keystroke. For datasets like a task list (hundreds, not millions, of rows), the in-memory filter is
cleaner and fast enough. If you were filtering millions of rows, moving the search into the SQL predicate would be the
right call.
Tip: For the common case of “no results found,” use
ContentUnavailableView.search(text:)— it provides a platform-standard empty state with the search term baked in, matching the system Messages and Files app behavior.Apple Docs:
.searchable— SwiftUI
Step 6: Priority Badges and Due Date Highlighting
Now upgrade the visual presentation. Replace the placeholder PriorityBadgeView with a version that uses color-coding,
and add overdue date highlighting to TaskRowView.
Priority Badges with Color
Replace the contents of Views/PriorityBadgeView.swift:
import SwiftUI
struct PriorityBadgeView: View {
let priority: TaskPriority
var badgeColor: Color {
switch priority {
case .critical: return .red
case .high: return .orange
case .medium: return .yellow
case .low: return .secondary
}
}
var body: some View {
Text(priority.rawValue.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(priority == .low ? .secondary : .white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(badgeColor)
.clipShape(Capsule())
}
}
Due Date Display with Overdue Highlighting
Add a computed property and due date label to TaskRowView.swift:
import SwiftUI
struct TaskRowView: View {
let task: PixarTask
private var isOverdue: Bool {
guard let dueDate = task.dueDate, !task.isCompleted else { return false }
return dueDate < Date()
}
private var dueDateText: String? {
guard let dueDate = task.dueDate else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: dueDate)
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .green : .secondary)
.font(.title3)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted, color: .secondary)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
HStack(spacing: 8) {
if let category = task.category {
Text(category.name)
.font(.caption)
.foregroundStyle(.secondary)
}
if let dateText = dueDateText {
Label(dateText, systemImage: "calendar")
.font(.caption)
.foregroundStyle(isOverdue ? .red : .secondary) // ← red if overdue
}
}
}
Spacer()
PriorityBadgeView(priority: task.priority)
}
.padding(.vertical, 4)
}
}
The isOverdue computed property checks three conditions: the task has a due date, it is not already completed (no
point highlighting a done task), and the due date is in the past relative to Date(). The “Write Act 2 outline” task
seeded in Step 1 with a due date two days ago will show its date in red.
Checkpoint: Build and run. The task list now shows color-coded priority badges — Critical tasks have red badges, High have orange, Medium have yellow. The “Write Act 2 outline” task shows its due date in red because it was seeded with an overdue date. Completed tasks have strikethrough titles and muted colors. If you toggle a task to completed (you’ll implement the swipe action next), its overdue styling disappears.
Step 7: Swipe to Complete
Swipe-to-delete is built into List’s onDelete, but marking a task complete deserves a dedicated swipe action on the
leading edge — the same pattern used by Reminders and Mail. Use SwiftUI’s
.swipeActions
modifier on the row.
Update TaskRowView.swift to add the swipe action. Because PixarTask is a SwiftData model class, you need access to
the model context to save the mutation. Pass it in as an environment value:
import SwiftUI
import SwiftData
struct TaskRowView: View {
let task: PixarTask
@Environment(\.modelContext) private var modelContext // ← for saving toggle
private var isOverdue: Bool {
guard let dueDate = task.dueDate, !task.isCompleted else { return false }
return dueDate < Date()
}
private var dueDateText: String? {
guard let dueDate = task.dueDate else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: dueDate)
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .green : .secondary)
.font(.title3)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted, color: .secondary)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
HStack(spacing: 8) {
if let category = task.category {
Text(category.name)
.font(.caption)
.foregroundStyle(.secondary)
}
if let dateText = dueDateText {
Label(dateText, systemImage: "calendar")
.font(.caption)
.foregroundStyle(isOverdue ? .red : .secondary)
}
}
}
Spacer()
PriorityBadgeView(priority: task.priority)
}
.padding(.vertical, 4)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
withAnimation {
task.isCompleted.toggle()
// SwiftData tracks the mutation automatically — no explicit save needed
}
} label: {
Label(
task.isCompleted ? "Reopen" : "Complete",
systemImage: task.isCompleted ? "arrow.uturn.backward" : "checkmark"
)
}
.tint(task.isCompleted ? .orange : .green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
modelContext.delete(task)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
Several SwiftData-specific behaviors are at work here:
- No explicit save call. SwiftData’s
@Modelobjects are reference types observed by the model context. Mutatingtask.isCompletedmarks the context as dirty, and SwiftData autosaves at the next natural checkpoint (end of the run loop, app backgrounding, or immediately whenisAutosaveEnabled: trueis set on the container). You do not calltry modelContext.save()for every mutation — only when you need a guaranteed flush. withAnimationwraps the toggle so the strikethrough and color change animate smoothly. SwiftData model mutations and SwiftUI animations compose naturally.- Leading vs. trailing swipe. The complete action is on the leading edge (swipe right) to match iOS system apps. The
destructive delete action is on the trailing edge (swipe left) with
allowsFullSwipe: false— requiring a deliberate tap rather than an accidental full swipe to prevent data loss.
Apple Docs:
.swipeActions— SwiftUI
Step 8: Task Detail and Edit View
The final piece is a detail view that lets users read the full task and edit any field inline. SwiftUI’s
@Bindable property wrapper turns an Observable object
(including SwiftData models) into a source of two-way bindings — the same role @State plays for value types.
Creating the Detail View
Create Views/TaskDetailView.swift:
import SwiftUI
import SwiftData
struct TaskDetailView: View {
@Bindable var task: PixarTask // ← @Bindable on a SwiftData model enables two-way bindings
@Query(sort: \TaskCategory.name) private var categories: [TaskCategory]
@Environment(\.modelContext) private var modelContext
@State private var hasDueDate: Bool
init(task: PixarTask) {
self.task = task
// Initialize hasDueDate from the existing task
_hasDueDate = State(initialValue: task.dueDate != nil)
}
var body: some View {
Form {
Section("Task") {
TextField("Title", text: $task.title)
TextField("Notes", text: $task.notes, axis: .vertical)
.lineLimit(3...8)
}
Section("Organization") {
Picker("Category", selection: $task.category) {
Text("None").tag(Optional<TaskCategory>.none)
ForEach(categories) { category in
Text(category.name).tag(Optional(category))
}
}
Picker("Priority", selection: $task.priority) {
ForEach(TaskPriority.allCases, id: \.self) { level in
Text(level.rawValue).tag(level)
}
}
.pickerStyle(.segmented)
}
Section("Due Date") {
Toggle("Has Due Date", isOn: $hasDueDate)
.onChange(of: hasDueDate) { _, newValue in
task.dueDate = newValue ? (task.dueDate ?? Date()) : nil
}
if hasDueDate, let dueDate = Binding($task.dueDate) {
DatePicker("Due", selection: dueDate, displayedComponents: [.date])
}
}
Section("Status") {
Toggle("Completed", isOn: $task.isCompleted)
LabeledContent("Created") {
Text(task.createdAt, style: .date)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Edit Task")
.navigationBarTitleDisplayMode(.inline)
}
}
@Bindable is the key to making this work cleanly. Because PixarTask is an @Model class (an Observable-backed
reference type), @Bindable can generate Binding values for each property — $task.title, $task.priority,
$task.isCompleted — that write directly back to the SwiftData store through the model context. There is no
intermediate copy, no save button, and no “edit mode” to enter. Every change the user makes is reflected in the database
in real time.
Apple Docs:
@Bindable— SwiftUI (also applicable to SwiftData models viaObservable)
Adding NavigationLink in TaskListView
Update the ForEach in TaskListView.swift to wrap each row in a NavigationLink:
ForEach(filteredTasks) { task in
NavigationLink(value: task) {
TaskRowView(task: task)
}
}
.onDelete(perform: deleteTasks)
Then add a navigationDestination to the NavigationStack in ContentView.swift’s detail column:
detail: {
NavigationStack {
TaskListView(selectedCategory: selectedCategory)
.navigationDestination(for: PixarTask.self) { task in
TaskDetailView(task: task)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Task", systemImage: "plus") {
isShowingCreateForm = true
}
}
}
}
}
For PixarTask to work as a NavigationLink value, it needs to conform to
Hashable. SwiftData’s @Model macro synthesizes
Hashable conformance automatically based on the model’s persistent identifier, so no additional code is needed.
Handling the Category Management Screen
While you’re here, let users add new categories too. Add a “Manage Categories” button to the sidebar and a simple
AddCategoryView. Create Views/AddCategoryView.swift:
import SwiftUI
import SwiftData
struct AddCategoryView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
// A small palette of Pixar-department-appropriate colors
private let colorOptions: [(label: String, hex: String)] = [
("Red", "#FF6B6B"),
("Teal", "#4ECDC4"),
("Yellow", "#FFE66D"),
("Green", "#A8E6CF"),
("Purple", "#C3A6FF"),
("Blue", "#74B9FF"),
]
@State private var selectedHex: String = "#74B9FF"
var body: some View {
NavigationStack {
Form {
Section("Department Name") {
TextField("e.g. Visual Development", text: $name)
}
Section("Color") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(colorOptions, id: \.hex) { option in
Circle()
.fill(Color(hex: option.hex))
.frame(width: 32, height: 32)
.overlay {
if selectedHex == option.hex {
Image(systemName: "checkmark")
.foregroundStyle(.white)
.font(.caption.weight(.bold))
}
}
.onTapGesture { selectedHex = option.hex }
}
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("New Department")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
let category = TaskCategory(name: name, colorHex: selectedHex)
modelContext.insert(category)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}
You’ll notice Color(hex:) — this is a convenience initializer not in the standard library. Add it in a new file
Extensions/Color+Hex.swift:
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: .init(charactersIn: "#"))
var rgb: UInt64 = 0
Scanner(string: hex).scanHexInt64(&rgb)
self.init(
red: Double((rgb >> 16) & 0xFF) / 255,
green: Double((rgb >> 8) & 0xFF) / 255,
blue: Double(rgb & 0xFF) / 255
)
}
}
Finally, add an “Add Department” button to CategorySidebarView.swift:
import SwiftUI
import SwiftData
struct CategorySidebarView: View {
@Query(sort: \TaskCategory.name) private var categories: [TaskCategory]
@Environment(\.modelContext) private var modelContext
@Binding var selectedCategory: TaskCategory?
@State private var isShowingAddCategory = false
var body: some View {
List(selection: $selectedCategory) {
Label("All Tasks", systemImage: "tray.full")
.tag(Optional<TaskCategory>.none)
Section("Departments") {
ForEach(categories) { category in
Label(category.name, systemImage: "folder")
.tag(Optional(category))
}
.onDelete(perform: deleteCategories)
}
}
.navigationTitle("Departments")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Department", systemImage: "folder.badge.plus") {
isShowingAddCategory = true
}
}
}
.sheet(isPresented: $isShowingAddCategory) {
AddCategoryView()
}
}
private func deleteCategories(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(categories[index])
}
}
}
Checkpoint: Build and run. This is the complete app. Tap any task in the list — you navigate to the detail view. Edit the title directly in the text field; when you navigate back, the list row reflects your change immediately. Toggle “Completed” in the detail view — the row shows a strikethrough in the list. Tap “Add Department” in the sidebar, create a “Visual Development” department, and it appears in the sidebar list and in the category picker of the create/edit forms. The full CRUD loop — Create, Read, Update, Delete — is working across both models with persistent storage.
Where to Go From Here?
Congratulations! You’ve built Pixar Production Tracker — a full-featured task manager with relational data modeling, reactive queries, swipe actions, search, inline editing, and on-device persistence through SwiftData.
Here’s what you learned:
- How to define
@Modelclasses with enum properties, optional relationships, and cascade/nullify delete rules using@Relationship - How to drive reactive SwiftUI lists with
@Queryand dynamic predicates using#Predicate - How to combine SQL-level category filtering with in-memory search text filtering without rebuilding the query on every keystroke
- How to implement swipe-to-complete and swipe-to-delete using
.swipeActionswith appropriate leading/trailing placement - How to build an inline-editing detail view using
@Bindableon a SwiftData model — no save button, no edit mode - How to seed sample data idempotently on first launch using
FetchDescriptorto check for existing records
Ideas for extending this project:
- Due date notifications. Use
UNUserNotificationCenterto schedule a local notification when a task’s due date approaches. ThedueDateproperty is already wired up — you just need to observe changes and schedule/cancel notifications accordingly. - iCloud sync. Pass
cloudKitDatabase: .automaticto yourModelConfigurationand SwiftData handles the CloudKit sync pipeline automatically. Your queries, relationships, and UI code require zero changes. - Home screen widget. Add a WidgetKit extension, share the SwiftData store using App Groups (the
ModelConfigurationtakes agroupContainer:parameter), and surface overdue Critical tasks on the home screen. - Batch operations. Explore
ModelContext.delete(model:where:)to delete all completed tasks at once — a “Clear Completed” feature that would make Buzz Lightyear proud.