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

Contents

Getting Started

Open Xcode and create a new project:

  1. Choose File > New > Project, then select the App template under iOS.
  2. Set the Product Name to PixarProductionTracker.
  3. Set the Interface to SwiftUI and the Language to Swift.
  4. Set your Minimum Deployments to iOS 18.0 in the project settings. SwiftData was introduced at iOS 17, and the #Predicate macro 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:

  • TaskPriority conforms to Codable so SwiftData can serialize the enum’s raw String value into the database column.
  • prioritySortOrder is a stored Int that mirrors priority.sortOrder. SwiftData sort descriptors require persistent stored properties — computed properties cannot be used as sort key paths. Setting this value in init ensures it stays in sync with the priority.
  • dueDate is optional (Date?) because not every production step has a hard deadline.
  • createdAt is set once in init and never mutated — it gives you a stable secondary sort key when two tasks have the same priority.
  • The category property is the “owning” side of the relationship. When SwiftData sees var category: TaskCategory? paired with the @Relationship(inverse:) on TaskCategory.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, and SampleData.swift are all members of the PixarProductionTracker target (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.insert is being called in PixarProductionTrackerApp.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) with lineLimit(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 Picker for selectedCategory uses Optional<TaskCategory> as the selection type. This is necessary because the category is optional — you need a .none tag to represent “no category selected.”
  • The “Add” button is disabled until title is 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 .nullify delete rule from Step 1).

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 @Model objects are reference types observed by the model context. Mutating task.isCompleted marks the context as dirty, and SwiftData autosaves at the next natural checkpoint (end of the run loop, app backgrounding, or immediately when isAutosaveEnabled: true is set on the container). You do not call try modelContext.save() for every mutation — only when you need a guaranteed flush.
  • withAnimation wraps 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 via Observable)

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 @Model classes with enum properties, optional relationships, and cascade/nullify delete rules using @Relationship
  • How to drive reactive SwiftUI lists with @Query and 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 .swipeActions with appropriate leading/trailing placement
  • How to build an inline-editing detail view using @Bindable on a SwiftData model — no save button, no edit mode
  • How to seed sample data idempotently on first launch using FetchDescriptor to check for existing records

Ideas for extending this project:

  • Due date notifications. Use UNUserNotificationCenter to schedule a local notification when a task’s due date approaches. The dueDate property is already wired up — you just need to observe changes and schedule/cancel notifications accordingly.
  • iCloud sync. Pass cloudKitDatabase: .automatic to your ModelConfiguration and 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 ModelConfiguration takes a groupContainer: 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.