Project 2 · Milestone 3 — the editor, swipe actions, and search
Unit 22 · Project 2 · Milestone 3. You build the detail editor (notes, flag, due date, priority, list), add swipe actions to the rows, and make the whole store searchable. By the end the Reminders clone is complete.
You can see reminders and complete them; now you need to edit them and find them. Both come
almost for free once the model is right — @Bindable binds a form straight to a persisted
object, and .searchable filters a query.
Step 1 — the reminder editor
Present a Form bound to the reminder with @Bindable. Every field writes straight through to
the persisted object — no "save" button, no copy-back:
struct ReminderDetailView: View { @Bindable var reminder: Reminder let lists: [ReminderList] @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @State private var hasDueDate: Bool init(reminder: Reminder, lists: [ReminderList]) { self.reminder = reminder self.lists = lists _hasDueDate = State(initialValue: reminder.dueDate != nil) } var body: some View { NavigationStack { Form { Section { TextField("Title", text: $reminder.title) TextField("Notes", text: $reminder.notes, axis: .vertical).lineLimit(2...5) } Section { Toggle(isOn: $reminder.flagged) { Label("Flag", systemImage: "flag.fill") } Toggle(isOn: $hasDueDate) { Label("Date", systemImage: "calendar") } if hasDueDate { DatePicker( "Due", selection: Binding( get: { reminder.dueDate ?? .now }, set: { reminder.dueDate = $0 } ), displayedComponents: [.date, .hourAndMinute] ) } } Section { Picker(selection: $reminder.priority) { ForEach(Priority.allCases) { Text($0.label).tag($0) } } label: { Label("Priority", systemImage: "exclamationmark") } Picker(selection: $reminder.list) { ForEach(lists) { list in Label(list.name, systemImage: list.symbol).tag(Optional(list)) } } label: { Label("List", systemImage: "list.bullet") } } } .navigationTitle("Details") .navigationBarTitleDisplayMode(.inline) .onChange(of: hasDueDate) { if !hasDueDate { reminder.dueDate = nil } else if reminder.dueDate == nil { reminder.dueDate = .now } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { if reminder.title.trimmingCharacters(in: .whitespaces).isEmpty { context.delete(reminder) // drop a reminder left blank } dismiss() } } } } } }
Two patterns worth naming. dueDate is optional, but DatePicker needs a non-optional binding
— so a hasDueDate toggle gates the picker, and onChange sets or clears dueDate to match.
And the List picker binds to $reminder.list with tag(Optional(list)): assigning it
moves the reminder between lists through the same relationship, and both lists' counts update.
Because the form is @Bindable straight onto the persisted Reminder, edits are live and
saved as you make them. The "Done" button here isn't committing a save — it's just dismissing
(and cleaning up a reminder someone opened and left blank).
Step 2 — present the editor and add swipe actions
Back in ReminderListView, hold the reminder being edited in @State, present the editor as a
sheet(item:), and give each row swipe actions — trailing to delete, leading to flag:
@State private var editing: Reminder? @Query(sort: \ReminderList.createdAt) private var lists: [ReminderList] // each row: ReminderRow(reminder: reminder) { editing = reminder } .swipeActions(edge: .trailing) { Button(role: .destructive) { context.delete(reminder) } label: { Label("Delete", systemImage: "trash") } } .swipeActions(edge: .leading) { Button { reminder.flagged.toggle() } label: { Label("Flag", systemImage: "flag") } .tint(.orange) } // on the List: .sheet(item: $editing) { reminder in ReminderDetailView(reminder: reminder, lists: lists) }
ReminderRow's onEdit closure — the empty {} from Milestone 1 — now sets editing, which
drives the sheet. Tapping a row opens the editor on that exact persisted object.
Step 3 — search the store
.searchable gives you a query string; filter your @Query results by it. Put search on the
home so it spans every list:
@State private var search = "" private var results: [Reminder] { let q = search.trimmingCharacters(in: .whitespaces).lowercased() guard !q.isEmpty else { return [] } return allReminders.filter { $0.title.lowercased().contains(q) || $0.notes.lowercased().contains(q) } } // in the body: when `search` is non-empty, show a Results section of matching // reminders instead of the tiles + lists. .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always))
Search matches titles and notes — so "forecast" finds the reminder whose note is "Include the revised forecast tab," even though that word isn't in its title.
Step 4 — the empty state
You already added ContentUnavailableView to ReminderListView in Milestone 2. Confirm it
reads well for a brand-new list and for a smart view with nothing in it — an empty Today should
say so, not show a blank screen.
Checkpoint
The full app should now:
- Open the editor on any reminder, change its title, notes, flag, due date, priority, and list, and see the change reflected everywhere (row, counts, smart views).
- Swipe a row to delete it, or swipe the other way to flag it.
- Search from the home and find reminders by a word in the title or the notes.
- Survive a force-quit with every reminder, list, and field intact.
Stretch goals
- Add list creation and editing (name, symbol, colour) with its own small editor.
- Add a priority sort so
!!!reminders rise to the top of a list. - Add a Scheduled view that groups reminders by day with section headers.
Knowledge check
Q: Why does the editor need no explicit save?
The Form is @Bindable onto the persisted Reminder, so each field writes straight to the
managed object and SwiftData tracks and saves the change. "Done" only dismisses (and deletes a
reminder left blank).
Q: The List picker binds to $reminder.list. What happens to the two lists' counts when you
change it, and why?
Both update automatically. Reassigning the relationship removes the reminder from the old list's
reminders and adds it to the new one's; the home's per-list counts are computed off those
relationships, so they recompute on the next render with no extra code.