Project 2 · Milestone 1 — the model, the relationship, and the list
Unit 22 · Project 2 · Milestone 1. You build the data model — reminders grouped into lists via a SwiftData relationship — attach a container, and render one list's reminders with a completion toggle. By the end the app persists reminders in a list across launches.
A Reminders app is two things that reference each other: a list owns many reminders, and every reminder belongs to a list. That's a relationship, and SwiftData models it directly. Get the graph right first; the screens hang off it.
Step 1 — the two models and the relationship
Create ReminderList and Reminder. Each is an @Model — a persisted, observable reference
type. The relationship is declared on the list side with @Relationship, and its inverse
points back at the property on Reminder that holds the owning list:
import SwiftData import SwiftUI @Model final class ReminderList { var name: String var symbol: String // an SF Symbol name var colorName: String var createdAt: Date @Relationship(deleteRule: .cascade, inverse: \Reminder.list) var reminders: [Reminder] = [] init(name: String, symbol: String = "list.bullet", colorName: String = "blue", createdAt: Date = .now) { self.name = name self.symbol = symbol self.colorName = colorName self.createdAt = createdAt } var color: Color { Palette.color(colorName) } var activeCount: Int { reminders.filter { !$0.isDone }.count } } @Model final class Reminder { var title: String var notes: String var isDone: Bool var dueDate: Date? var priorityRaw: Int var flagged: Bool var createdAt: Date var list: ReminderList? init(title: String, notes: String = "", isDone: Bool = false, dueDate: Date? = nil, priority: Priority = .none, flagged: Bool = false, createdAt: Date = .now) { self.title = title self.notes = notes self.isDone = isDone self.dueDate = dueDate self.priorityRaw = priority.rawValue self.flagged = flagged self.createdAt = createdAt } var priority: Priority { get { Priority(rawValue: priorityRaw) ?? .none } set { priorityRaw = newValue.rawValue } } }
Two things worth naming. deleteRule: .cascade means deleting a list deletes its reminders —
no orphans left behind. And the model is named Reminder, not Task, because a SwiftData
@Model final class Task would shadow Swift's own Task type — a real footgun the name
sidesteps.
SwiftData persists stored properties, not computed ones — so priority is stored as
priorityRaw (an Int) with a computed priority façade over it. Raw-valued enums are the
clean way to persist a small fixed set. Don't try to store the Priority enum directly.
Step 2 — the priority enum and the palette
Priority is a raw-valued enum so it can round-trip through priorityRaw. The palette maps a
stored colour name to a real Color:
enum Priority: Int, CaseIterable, Identifiable { case none, low, medium, high var id: Int { rawValue } var label: String { ["None", "Low", "Medium", "High"][rawValue] } var indicator: String { ["", "!", "!!", "!!!"][rawValue] } } enum Palette { static func color(_ name: String) -> Color { switch name { case "red": .red case "green": .green case "orange": .orange case "purple": .purple case "yellow": .yellow default: .blue } } }
Step 3 — attach the container
At the app root, .modelContainer(for:) builds the store and injects it into the environment.
Because there are two model types related to each other, register both:
import SwiftUI import SwiftData @main struct ChecklistApp: App { var body: some Scene { WindowGroup { RootView() } .modelContainer(for: [ReminderList.self, Reminder.self]) } }
Step 4 — the reminder row
A row shows the state that matters at a glance: a completion circle you can tap, the title
(struck through when done), any priority marker, a due date, and a flag. @Bindable gives the
row a two-way binding into the persisted Reminder, so toggling isDone saves automatically:
struct ReminderRow: View { @Bindable var reminder: Reminder var onEdit: () -> Void var body: some View { HStack(alignment: .top, spacing: 12) { Button { reminder.isDone.toggle() } label: { Image(systemName: reminder.isDone ? "largecircle.fill.circle" : "circle") .font(.title3) .foregroundStyle(reminder.isDone ? (reminder.list?.color ?? .accentColor) : .secondary) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { if reminder.priority != .none { Text(reminder.priority.indicator).foregroundStyle(.orange) } Text(reminder.title) .strikethrough(reminder.isDone) .foregroundStyle(reminder.isDone ? .secondary : .primary) } if !reminder.notes.isEmpty { Text(reminder.notes).font(.caption).foregroundStyle(.secondary).lineLimit(1) } if let due = reminder.dueDate { Text(due, format: .dateTime.weekday(.abbreviated).hour().minute()) .font(.caption) .foregroundStyle(!reminder.isDone && due < .now ? .red : .secondary) } } Spacer() if reminder.flagged { Image(systemName: "flag.fill").font(.caption).foregroundStyle(.orange) } } .contentShape(Rectangle()) .onTapGesture { onEdit() } } }
The Button around the circle handles its own tap; the onTapGesture on the whole row (with
contentShape(Rectangle()) so the empty space is tappable too) opens the editor — which you
build in Milestone 3. For now, pass an empty closure.
Step 5 — one list's reminders
Render a single list's reminders straight off the relationship (list.reminders), split into
active and completed sections. Because reminders is a SwiftData relationship, the view
updates when a reminder's isDone flips:
struct ListView: View { let list: ReminderList private var active: [Reminder] { list.reminders.filter { !$0.isDone } } private var completed: [Reminder] { list.reminders.filter { $0.isDone } } var body: some View { List { Section { ForEach(active) { ReminderRow(reminder: $0) {} } } if !completed.isEmpty { Section("Completed") { ForEach(completed) { ReminderRow(reminder: $0) {} } } } } .navigationTitle(list.name) } }
In Milestone 2 you'll generalize this into a view that can also render smart views (Today,
Flagged) by filtering a @Query of all reminders — the same rows, a different source. Build it
here against list.reminders first so you can see persistence working before you add the
smart-view machinery.
Checkpoint
With a RootView that shows a seeded list (create a ReminderList, insert a few Reminders
into modelContext, set each one's .list), your app should now:
- Show a list's reminders with priority markers, due dates, and flags.
- Toggle a reminder done on tap, moving it into the Completed section.
- Persist every change — force-quit and relaunch, and it's all still there.
Knowledge check
Q: Why is the relationship declared with inverse: on the list side?
It tells SwiftData that ReminderList.reminders and Reminder.list are the two ends of the
same relationship, so setting reminder.list = someList also makes the reminder appear in
someList.reminders. Without the inverse you'd have two unrelated properties to keep in sync
by hand.
Q: Why store priorityRaw: Int with a computed priority, instead of a stored Priority?
SwiftData persists stored properties; a raw-valued Int round-trips cleanly and stays stable
if you reorder the enum's cases later. The computed priority gives the rest of the app a
typed API over it.