EP 326 · Modern Persistence · May 26, 2025 ·Members

Video #326: Modern Persistence: Reminders Detail, Part 1

smart_display

Loading stream…

Video #326: Modern Persistence: Reminders Detail, Part 1

Episode: Video #326 Date: May 26, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep326-modern-persistence-reminders-detail-part-1

Episode thumbnail

Description

We begin building the “reminders” part of Apple’s Reminders app, including listing, creating, updating, and deleting them. We will also add persistent filters and sorts, per list, all powered by a complex, dynamic query.

Video

Cloudflare Stream video ID: 31cbaece97889a9852d1cd0a9f310125 Local file: video_326_modern-persistence-reminders-detail-part-1.mp4 *(download with --video 326)*

References

Transcript

0:05

Our little reminders app is starting to really take shape. We now have the ability to display the reminders lists along with a count of the number of incomplete reminders lists. And we did that in a single, simple SQL query which unfortunately is not possible to do in SwiftData. And we have the ability to create new lists of reminders, edit existing lists of reminders, all the while the view is observing changes to the database and animating changes.

0:30

And if that wasn’t cool enough, we are able to use all the tools of our libraries in an @Observable model, which means everything we’ve done so far is 100% testable, and sadly this is not the case for SwiftData. Brandon

0:43

So, we truly are starting to see what it means to have a “modern persistence” system in an iOS application. We get to leverage all of the powers of SQL, and let it shine, while also being able to leverage the powers of SwiftUI, and let it shine.

0:58

But we currently do not have a way to see the reminders that are in each list, nor do we have a way to create new reminders or edit existing ones. And in the process of implementing these features we are going to come across all types of advanced topics in crafting complex SQL queries, so let’s dig in! The reminder row and detail

1:16

We are going to start by creating a view that is just for the row that represents a reminder in a greater list of reminders. This view packs a surprising amount of logic and functionality, and so it would be best to build it in isolation. Let’s create a new file called ReminderRow.swift:

1:37

And I’ll just paste in the basic scaffolding of this view: import SharingGRDB import SwiftUI struct ReminderRow: View { let color: Color let isPastDue: Bool let reminder: Reminder let tags: [String] let onDetailsTapped: () -> Void var body: some View { HStack { HStack(alignment: .firstTextBaseline) { Button { <#Completion action#> } label: { Image( systemName: reminder.isCompleted ? "circle.inset.filled" : "circle" ) .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) } VStack(alignment: .leading) { HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) .foregroundStyle(reminder.isCompleted ? .gray : color) } Text(reminder.title) .foregroundStyle(reminder.isCompleted ? .gray : .primary) } .font(.title3) if !reminder.notes.isEmpty { Text( reminder.notes.replacingOccurrences(of: "\n", with: " ") ) .font(.subheadline) .foregroundStyle(.gray) .lineLimit(2) } subtitleText } } Spacer() if !reminder.isCompleted { HStack { if reminder.isFlagged { Image(systemName: "flag.fill") .foregroundStyle(.orange) } Button { onDetailsTapped() } label: { Image(systemName: "info.circle") } .tint(color) } } } .buttonStyle(.borderless) .swipeActions { Button("Delete", role: .destructive) { <#Delete action#> } Button(reminder.isFlagged ? "Unflag" : "Flag") { <#Flag action#> } .tint(.orange) Button("Details") { onDetailsTapped() } } } private var dueText: Text { if let date = reminder.dueDate { Text(date.formatted(date: .numeric, time: .shortened)) .foregroundStyle(isPastDue ? .red : .gray) } else { Text("") } } private var subtitleText: Text { let tagsText = tags.reduce(Text("")) { result, tag in result + Text(" #\(tag)") } return (dueText + tagsText.foregroundStyle(.gray) .bold()) .font(.callout) } } struct ReminderRowPreview: PreviewProvider { static var previews: some View { let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } Inner() } struct Inner: View { @FetchAll var reminders: [Reminder] var body: some View { NavigationStack { List { ForEach(reminders) { reminder in ReminderRow( color: .blue, isPastDue: false, reminder: reminder, tags: ["weekend", "fun"] ) { // No-op } } } } } } }

1:43

And already we can see reminders rendering in the preview.

1:48

There’s a lot here, but it’s mostly just the view hierarchy for the row, along with a bunch of placeholders for things we need to fill in. None of the actual functionality of this view has been implemented, and that will be our job.

2:02

But before that let’s step through a few particularly interesting things in this view. First, at the top of the view we have declared all of the data that this view needs to do its job: struct ReminderRow: View { let color: Color let isPastDue: Bool let reminder: Reminder let tags: [String] let onDetailsTapped: () -> Void … }

2:14

A few of these properties are very reasonable. We of course need to hold onto a reminder in the row because we need access to its various properties to create the view, such as title, priority, whether it is completed or not, and so on.

2:27

And there’s a onDetailsTapped closure that will allow the parent feature to be notified when one of the details buttons are tapped. And there’s two of them right now: a trailing button that is always visible, as well as a swipe action.

2:49

But the other properties might be a little more surprising. First, there’s color. It’s subtle, but in the Reminders app from Apple, various parts of the reminder row are colored based on the surrounding context of the list of reminders being viewed. Sometimes the color comes from color of the list the reminder is associated with, and sometimes a custom color is used based on viewing certain subsets of reminders, such as flagged, scheduled, completed, and so on. And so to support all of these use cases, we will have whoever constructs this view be responsible for computing the color and handing it to us.

3:44

Next there’s an isPastDue boolean. This may seem a little surprising because isn’t this fully determined by the dueDate on the reminder, which we have access to through the reminder field? Well, that is true, but then we would have to be responsible for doing some date math to check if the due date of the reminder is before today. But SQLite is capable of making these kinds of computations for us at the time of querying, and so we are going to let SQLite handle this for us.

4:26

And finally, there’s a tags property that is just a plain array of strings. We have this property because in the real Apple reminders app, each reminder row displays the list of tags associated with the reminder. But, interestingly, we are only holding onto an array of strings but in reality we have a dedicated Tag type: @Table struct Tag: Identifiable { let id: Int var title = "" }

5:08

However, for the purpose of this view we really only need the string title of the tag. There’s no need to fetch the full details of the tag. And of course there isn’t all that much extra data in this type beyond the title , but that may not always be the case. In the future this type may gain a lot more properties and data, and it would be wasteful to decode all of that information into memory when all we need is the title .

5:52

So, all of this is showing that we have gone through great lengths to model our domain as concisely as possible. The view needs very specific things to do its job, and we will spare no effort in making this data as concise as possible. This will allow us to show off some of the super powers of our StructuredQueries library that allows us to truly sculpt the data in our database into its most perfect form for a particular feature.

6:11

Scanning through the view we can see all the various ways this state is used. For example, we use the reminder data to figure out whether or not to fill the circle on the left of each row: Button { <#Completion action#> } label: { Image( systemName: reminder.isCompleted ? "circle.inset.filled" : "circle" ) .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) }

6:29

And here we take into account the priority of the reminder in order to render some exclamation marks in front of the title: HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) .foregroundStyle(reminder.isCompleted ? .gray : color) } Text(reminder.title) .foregroundStyle(reminder.isCompleted ? .gray : .primary) }

6:40

And we do a fancy transformation on the notes of each reminder to remove any newlines and then show only two lines of notes: if !reminder.notes.isEmpty { Text(reminder.notes.replacingOccurrences(of: "\n", with: " ")) .font(.subheadline) .foregroundStyle(.gray) .lineLimit(2) }

7:08

This way we get the gist of the note without taking up too much space.

7:12

And then things get really interesting in the part of the view where we compute the “subtitle” that appears just below the notes. This subtitle consists of the due date, if it is set, and it will be red if past due, followed by a list of tags prefixed with a “#” symbol. This can be done using a trick in SwiftUI that allows one to concatenate text views into larger text views: private var dueText: Text { if let date = reminder.dueDate { Text(date.formatted(date: .numeric, time: .shortened)) .foregroundStyle(isPastDue ? .red : .gray) } else { Text("") } } private var subtitleText: Text { let tagsText = tags.reduce(Text("")) { result, tag in result + Text(" #\(tag)") } return (dueText + tagsText.foregroundStyle(.gray) .bold()) .font(.callout) }

8:14

And then in various parts of this view we have action closures for completing reminders, deleting them, flagging them, and navigating to the details of the reminder. That will all come soon, but the last thing we want to call out is the preview: struct ReminderRowPreview: PreviewProvider { static var previews: some View { let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } Inner() } struct Inner: View { @FetchAll var reminders: [Reminder] var body: some View { NavigationStack { List { ForEach(reminders) { reminder in ReminderRow( color: .blue, isPastDue: false, reminder: reminder, tags: ["weekend", "fun"] ) { // No-op } } } } } } } Here we are preparing the database by migrating and seeding it with appDatabase , and then we use @FetchAll directly in the preview just as a means to fetch all reminders that were seeded. This gives us instant access to a whole bunch of reminders with a wide variety of settings in order for us to tweak our designs. And so we are seeing that our choice modernize how our app is created, prepared, seeded, and made available is paying dividends. We don’t have to do any work whatsoever to get reminders into this view since we can leverage our past work. And even better, this is exercise our actual tech stack instead of us short circuiting our true app logic in order for force a bunch of mock reminders into the view, which may be helpful in designing the views but may also be hiding bugs from us.

9:34

We can even implement a few pieces of behavior in this row already. For example, in the button that represents the isCompleted state we want to somehow toggle that state when the button is tapped. Now we of course can’t just reach into the state and mutate it directly: Button { reminder.isCompleted.toggle() } label: { … } Cannot use mutating member on immutable value: ‘reminder’ is a ‘let’ constant

10:00

First of all, this state is immutable, and second of all we want to make this change to the database . Not to this local piece of state.

10:29

To do this we need to make a write to the database, and so we can add a dependency on the defaultDatabase to our view: struct ReminderRow: View { … @Dependency(\.defaultDatabase) var database … }

10:45

And then in the button’s action closure we can start a write transaction: Button { try database.write { db in } } label: { … }

10:50

And in this transaction we want to execute an update query. Our StructureQueries library comes with some really powerful tools to make this super succinct and ergonomic.

11:01

Because Reminder is annotated with the @Table macro, it equipped with a method called update that takes a trailing closure: Reminder .update { }

11:10

This trailing closure is handed a value that has access to the table’s schema. For example, we can already access the isCompleted column on $0 : try Reminder .update { $0.isCompleted }

11:44

But this $0 is a little different from the schema that we have experienced before. We are allowed to make a certain set of very simple mutations to it, and those mutations will be re-interpreted as SQL that sets the properties in an

UPDATE 11:58

We could set isCompleted to true : .update { $0.isCompleted = true }

UPDATE 12:10

…or false: .update { $0.isCompleted = false }

UPDATE 12:13

We can even set it equal to the negation of itself: .update { $0.isCompleted = !$0.isCompleted }

UPDATE 12:24

And we have even provided a version of toggle that does this for us: .update { $0.isCompleted.toggle() }

UPDATE 12:39

This is pretty amazing. We get to write simple, type-safe Swift code that is translated to SQL code at the moment of execution: try Reminder .update { $0.isCompleted.toggle() } .execute(db)

UPDATE 12:43

However this is not quite right because this would update all reminders to toggle their isCompleted state. We only want to do this for the particular reminder that is associated with the row. To do this we can use the find method to scope this update to only that row: try Reminder .find(reminder.id) .update { $0.isCompleted.toggle() } .execute(db) And finally, because we are not in an error throwing context and because we think the only error that can be thrown here would be due to programmer error, not user error, we are going to wrap this in withErrorReporting : withErrorReporting { try database.write { db in try Reminder .find(reminder.id) .update { $0.isCompleted.toggle() } .execute(db) } }

UPDATE 14:32

We can do something similar for the flagging button: Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { try database.write { db in try Reminder .find(reminder.id) .update { $0.isFlagged.toggle() } .execute(db) } } }

UPDATE 15:03

And we can implement the “Delete” button by executing a “DELETE” statement: Button("Delete", role: .destructive) { withErrorReporting { try database.write { db in try Reminder .delete(reminder) .execute(db) } } }

UPDATE 15:20

We can give this a spin in the preview and see that everything is functional. We can complete reminders, flag them, and delete them. We can even tweak the query we are running in the preview to sort the completed reminders to the bottom: @FetchAll(Reminder.order(by: \.isCompleted)) var reminders

UPDATE 16:30

Now when we complete a reminder it goes the bottom. And we can even animation to the query so that when it detects changes to the database it will animate the changes: @FetchAll(Reminder.order(by: \.isCompleted), animation: .default) var reminders

UPDATE 16:53

Now when we complete reminders they animate to the bottom.

UPDATE 17:01

This is all looking great, but this is all we are going to sketch out for the row at this time. There will be a more to do soon, but we want to get the basics of our views into place before we dive into the more complex aspects of implementing our features.

UPDATE 17:17

Next we are going to get in the basic scaffolding of a reminders detail screen. This is a screen that can display a list of reminders, and over time it is going to evolve to be quite complex. It needs to be able to sort sorting and filtering the reminders. And it can display all reminders that are associated with a list, or associated with a tag, or some other kind of filter such as completed, flagged, scheduled, and so on.

UPDATE 17:48

And because this screen has so much complexity we are going to opt into it being designed an observable model from the get-go so that we will have a place to keep all logic and behavior so that it will be possible to write tests later: import SharingGRDB import SwiftUI @MainActor @Observable class RemindersDetailModel { } struct RemindersDetailView: View { @Bindable var model: RemindersDetailModel var body: some View { List { ForEach(<#Reminders#>) { reminder in ReminderRow( color: <#Color.blue#>, isPastDue: <#false#>, reminder: reminder, tags: <#["weekend", "fun"]#> ) { // Details button tapped in row } } } .navigationTitle(Text(<#Reminders#>)) .listStyle(.plain) .toolbar { ToolbarItem(placement: .bottomBar) { HStack { Button { <#New reminder action#> } label: { HStack { Image(systemName: "plus.circle.fill") Text("New Reminder") } .bold() .font(.title3) } Spacer() } .tint(<#Color.blue#>) } ToolbarItem(placement: .primaryAction) { Menu { Group { Menu { ForEach( <#["Due Date", "Priority", "Title"]#>, id: \.self ) { ordering in Button { <#Order action#> } label: { Label { Text(ordering) } icon: { <#Ordering icon#> } } } } label: { Text("Sort By") Label { Text(<#Current order#>) } icon: { Image(systemName: "arrow.up.arrow.down") } } Button { <#Show/hide completed action#> } label: { Label { Text( <#showCompleted#> ? "Hide Completed" : "Show Completed" ) } icon: { Image( systemName: <#showCompleted#> ? "eye.slash.fill" : "eye" ) } } } .tint(<#Color.blue#>) } label: { Image(systemName: "ellipsis.circle") .tint(<#Color.blue#>) } } } } } struct RemindersDetailPreview: PreviewProvider { static var previews: some View { let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } NavigationStack { RemindersDetailView(model: RemindersDetailModel()) } } } We can immediately see a preview that shows a few reminders, and there is a button for creating a new reminder, as well as a menu for sorting the reminders in various ways and filtering by just incomplete reminders or showing all. And one can swipe on each reminder row to expose a set of quick actions to perform on each reminder.

UPDATE 18:58

And again there are a lot of placeholders in this view of state and behavior we will have to fill in at some point, but for now everything is stubbed out. Filtering reminders

UPDATE 19:12

OK, we now have the scaffolding in place for two new features: a view that represents a reminder row displayed in a larger list, as well as a reminders detail feature that displays a list of reminders. We’ve even gone ahead and implemented some of the most basic functionality in the reminder row, such as completing, flagging, and deleting. Stephen

UPDATE 19:33

But now let’s move onto some more advanced querying functionality. By default the Reminders app on iOS does not show completed reminders. But there is a setting that allows you to see all reminders, even completed ones. Let’s see what it takes to implement this kind of functionality in our feature.

UPDATE 19:49

Right now all of the reminders showing in the preview are stubbed in the view. To get this powered from actual data that is in our database, we need to fetch the data. The place to do this is our observable model, and we’ve already seen this once before. We can use our @FetchAll property wrapper directly in an observable model: @MainActor @Observable class RemindersDetailModel { @ObservationIgnored @FetchAll var reminders: [Reminder] }

UPDATE 20:22

This simply fetches all reminders with not sorting or filtering. It’s quite similar to SwiftData’s @Query macro, but of course our tool works in the view and an observable model, whereas SwiftData only works in the view.

UPDATE 20:38

Next we can update the view to use this data from the model rather than the stubs: var body: some View { List { ForEach(model.reminders) { reminder in … } } … }

UPDATE 20:46

And just like that our view is displaying all reminders.

UPDATE 20:51

But of course this isn’t quite right. These reminders should be filtered to a particular subset, such as only reminders belonging to a particular list, and we are going to want to further filter for just incomplete ones, as well as perform sorts.

UPDATE 21:04

Let’s start with the simplest of these operations, which is being able to filter for just the incomplete reminders or show all reminders. We need to hold onto a piece of boolean state in our feature that controls this: @MainActor @Observable class RemindersDetailModel { … var showCompleted = false }

UPDATE 21:19

But we can go above and beyond. As we showed when giving a little tour of Apple’s Reminders app, this setting is persisted across app launches. So this boolean must be saved somewhere. The simplest way to save little bits of data like this is in user defaults. In SwiftUI one would use the @AppStorage property wrapper, but sadly that does not work properly except when placed directly in a view, and right now our data and logic is inside an observable model.

UPDATE 21:41

Well, luckily we have just the tool for this, and it’s called @Shared : @Shared

UPDATE 21:45

This tool comes from our Sharing library, which forms the foundation of what SharingGRDB is built upon. There are other persistence and fetching strategies that can be used besides SQLite, and user defaults is one: @Shared(.appStorage())

UPDATE 22:08

We can specify any string key in here: @Shared(.appStorage("showCompleted")) var showCompleted = false

UPDATE 22:14

…and we have to mark it as @ObservationIgnored because macros don’t play nicely with property wrappers, but in this case that is OK because the @Shared property wrapper, like @FetchAll , deals with observation on its own: @ObservationIgnored @Shared(.appStorage("showCompleted")) var showCompleted = false

UPDATE 22:24

That is all it takes to model a bit of boolean state in our model that is automatically persisted to user defaults, will cause the view to update when it is mutated, and it even observes changes to user defaults to make sure it is always kept in sync with the source of truth of the data. It’s even 100% testable since in tests it will use a temporary user defaults that is cleared after the test completes.

UPDATE 22:44

Now we can update our view to use this state: Text(model.showCompleted ? "Hide Completed" : "Show Completed") Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") Then we have a button action which is invoked when the user wants to toggle this setting. In the spirit of keeping as much feature logic and behavior out of the view we will simply invoke a method on the model: Button { model.toggleShowCompletedButtonTapped() } label: { … }

UPDATE 23:11

And we can implement this method by toggling the showCompleted state: func toggleShowCompletedButtonTapped() { $showCompleted.withLock { $0.toggle() } }

UPDATE 23:41

It may seem weird to have to use withLock here, but this is because @Shared state can be used anywhere in an app. This includes SwiftUI views, observable models, UIKit view controllers, actors, async code, really just anywhere . This means we have to be careful with synchronization, which is what withLock enforces. The @AppStorage property wrapper from SwiftUI does not do this because it’s primarily used from @MainActor contexts, such as views, but it technically is possible to corrupt your data with races if you use @AppStorage incorrectly.

UPDATE 24:23

Now we can write a SQL query that filters the reminders based on this bit of boolean state. We can’t do it at the declaration of reminders : @FetchAll var reminders: [Reminder]

UPDATE 24:33

…because the query we want to write is dynamic. It needs to take into account the showCompleted state and needs to recompute when it changes.

UPDATE 24:40

We will now create the FetchAll in the init of the model: init() { _reminders = FetchAll( Reminder.all ) }

UPDATE 24:54

We will craft our query by using the powerful query building tools that comes with our StructuredQueries library and is included automatically with SharingGRDB . We can start with a where clause because that’s how one can filter out rows that we do not want: init() { _reminders = FetchAll( Reminder.where { !$0.isCompleted } ) }

UPDATE 25:14

Now currently this unconditionally filters out all completed reminders. But sometimes we do want completed reminders, depending on how the showCompleted state is set.

UPDATE 25:24

One way to do this is to tweak the where predicate to say that if showCompleted is true, then show all all reminders, and otherwise apply the “not is completed” filter: init() { _reminders = FetchAll( Reminder.where { showCompleted || !$0.isCompleted } ) }

UPDATE 25:36

This query is basically translated into something like this: WHERE 1 OR NOT isCompleted …or: WHERE 0 OR NOT isCompleted …depending on the value of showCompleted .

UPDATE 25:49

And it’s worth mentioning that this code looks very similar to what one does in SwiftData for constructing dynamic queries. Really, almost everything we have done so far looks very similar to what one does in SwiftData, but our tools work directly with SQLite, work outside of SwiftUI views, and have been more testable.

UPDATE 26:06

There’s also an alternative where we leverage the fact that the trailing closure of where has a limited result builder context, allowing us to do simple logical operations: init() { _reminders = FetchAll( Reminder.where { if !showCompleted { !$0.isCompleted } } ) }

UPDATE 26:26

It’s really up to you which style you like best.

UPDATE 26:29

Now although this query is dynamic, it is unfortunately set a single time when the model is created and it will not update when the showCompleted state changes. To do this let’s first extract out the query to a helper that can be invoked from multiple places: var remindersQuery: ??? { Reminder .where { if !showCompleted { !$0.isCompleted } } }

UPDATE 26:52

But the question is: what is the type of this query?

UPDATE 26:56

There are many types of statements that one can construct in SQL, and in this case we are constructing a

SELECT 27:10

And we can preserve the type information of what kind of data we are selecting by using the SelectStatementOf type alias: var remindersQuery: some SelectStatementOf<Reminder> { … }

SELECT 27:23

Now we can use this helper from the initializer: init() { _reminders = FetchAll(remindersQuery) }

SELECT 27:28

But more importantly, we can also define a method that updates the query based on the current state in the model: func updateQuery() { }

SELECT 27:41

The @FetchAll property wrapper exposes a projected value: $reminders

SELECT 27:45

…which exposes new functionality for loading data from the database. In particular, there is a load method: $reminders.load

SELECT 28:00

…and this method takes a query so that the property wrapper can fetch the newest data from the database. And this is the perfect time to make use of our remindersQuery helper: $reminders.load(remindersQuery)

SELECT 28:10

Now this method is async and throwing. It’s async because it suspends while the database query is being executed, which can be handy for displaying loading indicators in the view. And it’s throwing so that you can handle any errors thrown by executing the query.

SELECT 28:26

Now, as we’ve said before, any errors thrown by SQLite are most likely due to programmer error and not user error, and so there isn’t that much we can tell the user. So, in those situations we like to wrap the throwing code in withErrorReporting : func updateQuery() { await withErrorReporting { try await $reminders.load(remindersQuery) } }

SELECT 28:48

And now we just need to provide an async context for us to load the data: func updateQuery() async { … }

SELECT 28:52

Now that we have a method for updating the query that powers our reminders state, we need to invoke it when the showCompleted state changes. To do this we do need to upgrade it to be async so that we can invoke updateQuery : func toggleShowCompletedButtonTapped() async { $showCompleted.withLock { $0.toggle() } await updateQuery() }

SELECT 29:13

And that pushes the responsibility of providing an async context to the view, and that is where it is appropriate to spin up an unstructured task: Button { Task { await model.toggleShowCompletedButtonTapped() } } label: { … }

SELECT 29:25

We’ve discussed this many times on Point-Free before, but it is best to push any unstructured concurrency into your view, which is already unstructured, and try your hardest to embrace structured concurrency in your models. This will make your models easier to understand and easier to test.

SELECT 29:41

And just like that everything should now work. We can load up the preview and immediately see that only incomplete reminders are being displayed. But then we can open up the menu in the top-right and choose to show completed, and then instantly the view updates showing all reminders.

SELECT 29:57

However, things are a bit jarring right now. The reminders do not animate in and out like one might expect. Luckily there is an easy solution to this. When loading the new query into $reminders we can do so with an animation: try await $reminders.load(remindersQuery, animation: .default) Now we see animations working nicely! Filtering by reminders list

SELECT 30:21

This is starting to look really nice. We are able to toggle between the states of showing just incomplete reminders or showing all reminders. And we are even saving that state in user defaults so that the app remembers the user’s preferences. Brandon

SELECT 30:34

However, we are still showing all reminders, regardless of the list. But this reminders detail feature is supposed to show us only a subset of reminders, such as the ones belonging to a particular list.

SELECT 30:47

Let’s see what it takes to achieve this.

SELECT 30:50

In order to filter the reminders in this screen based on the list they belong to we could just require that a list be provided when constructing this view: @MainActor @Observable class RemindersDetailModel { … let remindersList: RemindersList … }

SELECT 31:07

And then we would use that list to filter out reminders that do not belong to the list.

SELECT 31:12

But let’s go above and beyond. We want this single feature to service many types of reminders details, such as all reminders belonging to a tag, or all completed reminders, or all scheduled reminders, and on and on and on.

SELECT 31:31

To service all of these use cases we will create an enum to enumerate the types of details that can be viewed: enum DetailType { case remindersList(RemindersList) }

SELECT 31:59

And then this is the state we will hold in our model: @MainActor @Observable class RemindersDetailModel { … let detailType: DetailType … }

SELECT 32:03

Then we have to update the initializer of the model to accept this data from the outside: init(detailType: DetailType) { self.detailType = detailType _reminders = FetchAll(remindersQuery) }

SELECT 32:28

And this means we now need to update the preview to pass in a detailType when constructing the model. We could just construct a RemindersList from scratch: model: RemindersDetailModel( detailType: .remindersList(RemindersList(…)) )

SELECT 32:58

But this list would not be persisted to the database, and therefore it wouldn’t have any reminders to show. A better technique is to simply fetch a reminders list right after preparing the database and using that in the preview: struct RemindersDetailPreview: PreviewProvider { static var previews: some View { let remindersList = prepareDependencies { $0.defaultDatabase = try! appDatabase() return try! $0.defaultDatabase.read { db in try RemindersList.find(1).fetchOne(db)! } } NavigationStack { RemindersDetailView( model: RemindersDetailModel( detailType: .remindersList(remindersList) ) ) } } }

SELECT 34:06

Now we are using real data from the database to provide to our view. We do want to comment that this style of making database requests right along side constructing the view is only appropriate to do in previews. We do not suggest doing this in the bodies of your views as view bodies can be called many dozens or hundreds of times, and it would be wasteful to execute database requests during that process.

SELECT 34:50

But, of course we aren’t actually using the detailType at all in order to filter out reminders. We can do this with another where clause in the remindersQuery helper: var remindersQuery: some SelectStatementOf<Reminder> { Reminder .where { if !showCompleted { !$0.isCompleted } } .where { } }

SELECT 35:28

In this trailing closure we can again leverage result builder syntax in order to perform a switch on the detailType in order to decide what kind of filtering logic we are going to apply: .where { switch detailType { } }

SELECT 35:51

When detailType is remindersList we can select only the reminders whose list ID matches the list in the detail: .where { switch detailType { case .remindersList(let remindersList): $0.remindersListID.eq(remindersList.id) } }

SELECT 36:13

And just like that we are now filtering out irrelevant reminders when previewing the feature. It’s pretty incredible how easy it is to construct complex queries with our library.

SELECT 36:27

But we can apply most customizations to our view based on this detailType . For example, we could define a helper for constructing an appropriate title for the view from the detailType : enum DetailType { case remindersList(RemindersList) var navigationTitle: String { switch self { case .remindersList(let remindersList): remindersList.title } } }

SELECT 36:51

And then use that in the view: .navigationTitle( Text(model.detailType.navigationTitle) )

SELECT 37:00

Immediately we see the title update to “Personal”, which is the list we are viewing, rather than the generic “Reminders”.

SELECT 37:19

We can also define a helper that computes the color we should use for accent colors in the view: enum DetailType { … var color: Color { switch self { case .remindersList(let remindersList): remindersList.color.swiftUIColor } } }

SELECT 37:43

And then we have a few places we can update to use this color: ReminderRow( color: model.detailType.color, … ) …and here: .tint(model.detailType.color)

SELECT 38:11

And just to see that this works we could select a different list, like family: try RemindersList.find(2).fetchOne(db)! …or business: try RemindersList.find(3).fetchOne(db)!

SELECT 38:21

This is looking pretty great, but we can go above and beyond again . Another fun feature we demonstrated in Apple’s version of the Reminders app is that the showCompleted state is actually bucketed on a per-list basis. This means we are allowed to show all reminders for our “Personal” list, while over on our “Business” list we may want to only show incomplete reminders.

SELECT 38:54

We can support this by providing a custom key for our @Shared(.appStorage) that incorporates the properties of the detailType . But, since the key is now dynamic we can no longer specify the key at the state’s declaration: @ObservationIgnored @Shared var showCompleted: Bool

SELECT 39:24

…and instead declare it in the initializer where we have access to detailType : init(detailType: DetailType) { … _showCompleted = Shared( wrappedValue: false, .appStorage("showCompleted_???") ) … }

SELECT 39:57

But, the question is: what should we suffix the key with?

SELECT 39:58

Well, we can define yet another helper on DetailType for this suffix: enum DetailType { … var appStorageKeySuffix: String { switch self { case .remindersList(let remindersList): "remindersList_\(remindersList.id)" } } }

SELECT 40:23

And then incorporate that: init(detailType: DetailType) { … _showCompleted = Shared( wrappedValue: false, .appStorage("showCompleted_\(detailType.appStorageKeySuffix)") ) … }

SELECT 40:39

And now we can make sure that these settings are bucketed to each list. We unfortunately can’t easily show this off yet because we haven’t yet added the ability to navigation from the root screen of reminders list to this detail screen, but we can show it off in isolation by overriding the preview’s app storage to the standard user defaults, which persist across runs: let _ = prepareDependencies { … $0.defaultAppStorage = .standard … }

SELECT 41:49

Now when we run a preview against a particular list and update its showCompleted , we’ll see that it persists this state, per list! Sorting

SELECT 42:58

OK, now we are cooking! Our query is getting more and more complex, now able to take into account the surrounding detail context that determines which reminders we should be displaying. And we even upgraded the state that determines whether or not to show completed reminders so that it is a per-list setting, rather than it being a global setting. It’s just really amazing how easy it is to iteratively add more and more functionality to this app when you are using modern tools. Stephen

SELECT 43:26

There is one last piece of querying functionality we need to implement in this feature. We want the ability to customize how the reminders are sorted. We should be able to sort by due date, priority or title.

SELECT 43:38

Let’s see what it takes to make our query even more complex.

SELECT 43:43

Let’s start by modeling an enum that represents the different ways we want to be able to order reminders: enum Ordering: String, CaseIterable { case dueDate = "Due Date" case priority = "Priority" case title = "Title" } We’ve also gone ahead and made this enum raw representable by String and CaseIterable , which will help in the view.

SELECT 43:58

Next we can add some state to our model that represents the current order we are displaying the reminders. And just as with the showCompleted state, we will power this by our @Shared property wrapper: @MainActor @Observable class RemindersDetailModel { … @ObservationIgnored @Shared var ordering: Ordering … }

SELECT 44:17

And also just like showCompleted we will initializer this with a user defaults key that takes into account the list we are viewing: init(detailType: DetailType) { … _ordering = Shared( wrappedValue: .dueDate, .appStorage("ordering_\(detailType.appStorageKeySuffix)") ) … }

SELECT 44:37

Next we want to modify the query to take into account this ordering state. First, let’s sort all reminders to put the incomplete ones first: var remindersQuery: some SelectStatementOf<Reminder> { Reminder .where { if !showCompleted { !$0.isCompleted } } .where { switch detailType { case .remindersList(let remindersList): $0.remindersListID.eq(remindersList.id) } } .order { $0.isCompleted } }

SELECT 44:59

Regardless of our filters and sorts on this screen, we will always want incomplete reminders to be at the top.

SELECT 45:13

But, after that order, we want to further order by either the due date, priority or title. So we will tack on another order : .order { }

SELECT 45:23

And just like the where method, order too provides a very slimmed result builder context for performing simple logical expressions. So, in the trailing closure we can switch on the ordering state to figure out what to order by: .order { switch ordering { } }

SELECT 45:32

For the dueDate case we will sort by the due date of reminders in an ascending fashion, which is the default in SQLite: case .dueDate: $0.dueDate

SELECT 45:47

However, sorting in an ascending fashion means that reminders with no due date will be put first. That is probably not what we want. So we can further say that we want nulls to be last: case .dueDate: $0.dueDate.asc(nulls: .last)

SELECT 46:06

For the priority case we will sort by priority in a descending fashion so that high priority reminders are first: case .priority: $0.priority.desc()

SELECT 46:13

And in this case reminders with no priority will be put last.

SELECT 46:16

We can improve this a bit by further sorting by the isFlagged column whenever two reminders have the same priority. We will want to do so in a descending fashion so that flagged reminders are put before unflagged: case .priority: ($0.priority.desc(), $0.isFlagged.desc())

SELECT 46:35

It’s amazing to see how the power of Swift parameter packs allow us to simply tuple up these columns.

SELECT 46:40

And then finally in the title case we will sort by the title in an ascending fashion: case .title: $0.title

SELECT 46:45

That is all it takes update the query for our reminders.

SELECT 46:51

Next we will start updating the view to make use of the new ordering state. We can use the allCases static to display all ordering options in the menu: ForEach(Ordering.allCases, id: \.self) { ordering in Button { … } label: { Text(ordering.rawValue) … } }

SELECT 47:10

And we can even define an icon helper on Ordering that derives an appropriate icon to use in the menu: enum Ordering: String, CaseIterable { … var icon: Image { switch self { case .dueDate: Image(systemName: "calendar") case .priority: Image(systemName: "chart.bar.fill") case .title: Image(systemName: "textformat.characters") } } }

SELECT 47:25

And render it right next to the title of the order: Button { … } label: { Label { Text(ordering.rawValue) } icon: { ordering.icon } }

SELECT 47:29

Then, when an ordering button is tapped we will need to invoke a method on the model. This will look very similar to what we did for showCompleted , which needed to be async and so we will spin up an unstructured task in the view where it is most appropriate: Button { Task { await model.orderingButtonTapped(ordering) } } label: { … }

SELECT 47:49

And then we can implement this method by updating the ordering shared state, waiting for a brief moment in order to give SwiftUI’s animation system to figure things out, and then finally update the query that drives the view: func orderingButtonTapped(_ ordering: Ordering) async { $ordering.withLock { $0 = ordering } await updateQuery() }

SELECT 48:22

And just like that we now have fully functional sorting in our reminders detail. And anytime we change the sort, reminders animate into place. It’s worth mentioning that even SwiftData does not correctly handle animating when incorporating dynamic queries. If you change a @Query ’s predicate to select a different subset of rows from your database, SwiftData will not animate those changes. But with our tools we get it for free.

SELECT 49:12

And it’s also worth mentioning just how powerful our query is becoming for this view: var remindersQuery: some SelectStatementOf<Reminder> { Reminder .where { if !showCompleted { !$0.isCompleted } } .where { switch detailType { case .remindersList(let remindersList): $0.remindersListID.eq(remindersList.id) } } .order { $0.isCompleted } .order { switch ordering { case .dueDate: $0.dueDate.asc(nulls: .last) case .priority: ($0.priority.desc(), $0.isFlagged.desc()) case .title: $0.title } } }

SELECT 49:17

This one query handles selecting only the incomplete reminders if needed, as well as selecting only reminders belonging to a particular list. It orders those reminders so that the incomplete are up top, and then further orders them by either their due date, priority or title. Sure, it’s a mouthful of a query, but it can also be expressed as one single expression and does not need to be split up into a bunch of different pieces for no reason. Next time: SharingGRDB vs. SwiftData

SELECT 49:46

This one query handles selecting only the incomplete reminders if needed, as well as selecting only reminders belonging to a particular list. It orders those reminders so that the incomplete are up top, and then further orders them by either their due date, priority or title. Sure, it’s a mouthful of a query, but it can also be expressed as one single expression and does not need to be split up into a bunch of different pieces for no reason.

SELECT 50:12

It’s just really incredible to see how easy it is to build a complex query with our libraries. And each step of the way we get static access to the schema of our tables that helps prevent typos, type-mismatches, or just constructing non-sensical queries. Brandon

SELECT 50:27

However, to really drive home how incredible we think this is, we want to pause our journey towards recreating Apple’s reminders app to take a moment and reflect. We personally think that these tools provide a great alternative to SwiftData. They accomplish most of what SwiftData accomplishes, and the code written often looks very similar to SwiftData, but our tools give us full, unfettered access to the power of SQL, which we are really starting to take advantage of now.

SELECT 50:56

So, let’s take a moment to dabble in some SwiftData. Let’s see what it looks like if we were to try to rebuild this complex query using the tools that SwiftData gives us. I think our viewers are going to be pretty surprised by what we uncover here…next time! References SQLiteData A fast, lightweight replacement for SwiftData, powered by SQL. https://github.com/pointfreeco/sqlite-data StructuredQueries A library for building SQL in a safe, expressive, and composable manner. https://github.com/pointfreeco/swift-structured-queries Downloads Sample code 0326-modern-persistence-pt4 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .