Project 2 · Milestone 3 — the editor, swipe actions, and searchswift-6.4/ios-26
Lesson 4 / 5
Unit 22 · Checklist

Project 2 · Milestone 3 — the editor, swipe actions, and search

Note

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.

Tip

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.