SwiftData: Solving Filtering by an Entity in the Predicate
When SwiftData was first announced I decided to leave it alone and migrate over later when hopefully there would be more support. Then I realized now, before my app enters the App Store and has users with data, may be the best time to switch over. That said, I've had difficulties getting the same functionalities working and have had to give up some of my loved bits for now. Filtering my browse lists by an entity relationship; however, wasn't one I could give up and I had trouble solving it for awhile. And like all issues it was a simple solution.
My simpler app ideas have browse views that were easy to migrate over to SwiftData but my favorite app has more complicated browse views that limit the content based on a relationship in the data. Unlike the user filtering and sectioned lists that I decided to forgo for now, this filtering was pivotal to my app's functionality and as such I knew I needed to get it working.
The Problem
Source Example Code
A quick aside on the example code. When getting a handle on SwiftData I started out with Hacking with Swift's free (as of right now on December 9th, 2023) SwiftData by Example online book. In case things change the download version, currently showing up as $30 for me in the U.S., can be found here. He starts the book by going over a sample project walkthrough (starting here) that can also be found on his GitHub page. The project, iTour, creates a simple app that keeps track of potential destinations you could visit along with their related sights. So for this data each Destination entity can have zero to many attached Sight entities while each Sight has only one connected Destination.
The Problem Itself
My problem was that I wasn't able to filter my query by another entity within the predicate. Whatever I tried failed to build while also taking some time for such attempts to fail which further aggravated the situation. Since I had already created the SwiftData by Example walkthrough project and have a membership with access to his forum I decided to recreate my problem using his code as the base and post it to his forum here. I didn't receive any replies but on there you can see the process I went through, the temporary solution I hacked together so I had a working browse allowing me to take a break to work on other things, and my eventual solution which of course is also shown below.
To demonstrate this issue I took the sample project and changed the SightsView
file to have two views so you can filter the list of Sights by a search term, change the sort, and, most importantly for me now, pass in an optional Destination
entity to further filter the list of Sights. For the reasoning behind the two views for the browse you can check out this section of his walkthrough while the predicate logic itself is in the next section here. For this code change, as I had trouble with compiling and wanted a quick example, I only passed in nil for the Destination
.
The code showing the problem, replacing the contents of the SightsView
file, is:
import SwiftUI
import SwiftData
struct SightsView: View {
@State private var path = [Sight]()
@State private var sortOrder = [SortDescriptor(\Sight.name)]
@State private var searchText = ""
var body: some View {
NavigationStack(path: $path) {
SightsListingView(sort: sortOrder, searchString: searchText, destination: nil)
.navigationTitle("Sights")
.searchable(text: $searchText)
}
}
}
struct SightsListingView: View {
@Environment(\.modelContext) var modelContext
@Query var sights: [Sight]
init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
_sights = Query(filter: #Predicate {
if destination == nil {
return $0.name.localizedStandardContains(searchString)
// below assuming it's equal now si
} else if searchString.isEmpty {
return $0.destination == destination
} else {
return $0.destination == destination && $0.name.localizedStandardContains(searchString)
}
// Other attempt
// if destination == nil && searchString.isEmpty {
// return true
// } else if destination == nil {
// return $0.name.localizedStandardContains(searchString)
// } else if searchString.isEmpty {
// return $0.destination == destination
// } else {
// return $0.destination == destination && $0.name.localizedStandardContains(searchString)
// }
}, sort: sort)
}
var body: some View {
List {
ForEach(sights) { sight in
VStack(alignment: .leading) {
Text(sight.name)
.font(.headline)
}
}
.onDelete(perform: deleteSights)
}
}
func deleteSights(_ indexSet: IndexSet) {
for index in indexSet {
let sight = sights[index]
modelContext.delete(sight)
}
}
}
Building Issues
My problem was that anything I attempted in the Predicate
to filter the listed Sights by the parent Destination hung when attempting to build and was never able to complete the build. One of the errors was:
The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
In addition to that I also received the odd error where the compiler didn't time out but the predicate itself was problematic. These errors were still similar to the expanded code shown above.
The Solution
Path to the Solution
I tried many attempts to solve the issue before settling for a hacky version of the code that compiled, showed the right data, yet didn't update so I could temporarily set aside the problem and attempt to migrate other bits of my app over. That said, this problem hung over my head as I knew this was pivotal and thus needed to be fixed if I was ever going to keep using SwiftData in the future.
After solving another major for me issue I decided to stop putting this off and again tried to look up solutions online where I found a StackOverflow question titled SwiftData @Query with #Predicate on Relationship Model. The question and solution didn't spark anything major but the backup answer, pictured here, did help me come to the proper solution.
I went back to my code and removed the Destination
itself from the predicate and instead used its precomputed id
. This worked in the sample project, shown in the next section, but didn't work in my actual app. A quick update from id
to the persistentModelID
; however, fixed it there and thus the final solution was found.
Compiled and Filtering Worked
With the solution finally found the code was able to successfully compile, the app ran, and the resulting Sights list was empty! Yes!!!!
With the code compiling and working properly by showing no Sights, as the Destination
passed in was nil, I quickly moved to the Sights section within the EditDestinationView
file and added a Button
to open a sheet to the browse so I could pass in an actual Destination
.
// ...
// Bool to handle opening the sheet with the browse
@State private var showBrowse: Bool = false
var body: some View {
// ...
Section("Sights") {
// Quick button to show the filtering in action
Button(action:
showBrowse = true
}, label: {
Text("See All Sights (\(destination.sights.count))")
})
.sheet(isPresented: $showBrowse, content: {
// And pass in the current destination
// so we only see the sights also shown below
SightsView(destination: destination)
})
ForEach(sortedSights) { sight in
// ...
}
}
// ...
}
// ...
And here's what the sights looked like opened from different Destination
views.
What the Problem Was
The simple solution to this problem was that you can't include an entity other than the one you're currently filtering which, in this case, was Sight
. Thus the very act of including Destination
broke the predicate. Spring boarding off of the StackOverflow question listed above I instead took the persistentModelID
from the Destination
entity passed in and used that resulting ID
within the predicate to filter the sights by.
The Final Code That Works
As such my code for the SightsView
stayed the same while the init
itself, in my SightsListingView
, changed to the following:
init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
// Make a point of grabbing
if let thisID = destination?.persistentModelID {
_sights = Query(filter: #Predicate<Sight> {
if searchString.isEmpty {
return $0.destination?.persistentModelID == thisID
} else {
return $0.destination?.persistentModelID == thisID && $0.name.localizedStandardContains(searchString)
}
}, sort: sort)
} else {
// if id is nil than I don't want ANYTHING
_sights = Query(filter: #Predicate<Sight> { currSight in
// If you remove the 'currSight in' you get error: Cannot convert value of type '() -> Bool' to expected argument type '(Sight) -> Bool'
return false
}, sort: sort)
}
}
I took a quick screenshot to show the gorgeousness of the resulting code in Xcode.
TLDR: The Code
The final browse form code showing all the changes to the SwiftData by Example sample project can simply overwrite the SightsView
file with the following:
import SwiftUI
import SwiftData
struct SightsView: View {
@State private var path = [Sight]()
@State private var sortOrder = [SortDescriptor(\Sight.name)]
@State private var searchText = ""
@State var destination:Destination?
var body: some View {
NavigationStack(path: $path) {
SightsListingView(sort: sortOrder, searchString: searchText, destination: destination)
.navigationTitle("Sights")
.searchable(text: $searchText)
}
}
}
struct SightsListingView: View {
@Environment(\.modelContext) var modelContext
@Query var sights: [Sight]
init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
if let thisID = destination?.persistentModelID {
_sights = Query(filter: #Predicate<Sight> {
if searchString.isEmpty {
return $0.destination?.persistentModelID == thisID
} else {
return $0.destination?.persistentModelID == thisID && $0.name.localizedStandardContains(searchString)
}
}, sort: sort)
} else {
// if id is nil than I don't want ANYTHING
_sights = Query(filter: #Predicate<Sight> { currSight in
// If you remove the 'currSight in' you get error: Cannot convert value of type '() -> Bool' to expected argument type '(Sight) -> Bool'
return false
}, sort: sort)
}
}
var body: some View {
List {
ForEach(sights) { sight in
VStack(alignment: .leading) {
Text(sight.name)
.font(.headline)
}
}
.onDelete(perform: deleteSights)
}
}
func deleteSights(_ indexSet: IndexSet) {
for index in indexSet {
let sight = sights[index]
modelContext.delete(sight)
}
}
}
Of course this also included the quick button I added to the EditDestinationView
within the Sights section:
// Bool to handle opening the sheet with the browse
@State private var showBrowse: Bool = false
var body: some View {
// ...
Section("Sights") {
// Quick button to show the filtering in action
Button(action:
showBrowse = true
}, label: {
Text("See All Sights (\(destination.sights.count))")
})
.sheet(isPresented: $showBrowse, content: {
// And pass in the current destination
// so we only see the sights also shown below
SightsView(destination: destination)
})
ForEach(sortedSights) { sight in
// ...
}
}
// ...
}
And with that the code was fixed and my browse views could again be fetched and updated properly!
I hope this code fix helps you have a great day. If you have any questions and/or tips I'd love to hear them so feel free to share them in the comments below.
If youβre interested in getting any of my future blog updates I normally share them to my Facebook page and Instagram account. Youβre also more than welcome to join my email list located right under the search bar or underneath this post.