Starting with iOS 15, it’s easier to dynamically configure a Core Data fetch request for use with SwiftUI.
Creating Fetch Requests
In iOS 15, Apple gave SwiftUI a more convenient version of the property wrapper for creating Core Data fetch requests. For example, here’s a fetch request created in iOS 14:
// iOS 14
@FetchRequest(
entity: Country.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Country.name, ascending: true)
])
private var countries: FetchedResults<Country>
This fetches Country
managed objects, sorting the results in ascending order by name. In iOS 15, you can drop the entity
as the fetch request property wrapper can infer it from the result. We also have a new SortDescriptor
value type:
// iOS 15
@FetchRequest(sortDescriptors: [SortDescriptor(\.name, order: .forward)])
private var countries: FetchedResults<Country>
The sort order parameter can be .forward
or .reverse
(forward is the default). When sorting a string you can also give a comparator. The default is .localizedStandard
:
SortDescriptor(\.name, comparator: .lexical, order: .reverse)
Dynamic Configuration
The big change in iOS 15 is that the fetch request now exposes the sort descriptors and predicate as parameters so you can update them at runtime. This is a big help when you want to filter, reorder or search a collection of managed objects.
Let’s see an example. Here’s my list of country objects populated from a Core Data fetch request. I have both a control to filter the list to show just visited countries and a search field:
I’ve extracted the state of the visited filter and the search query into a SearchScope
struct for easier testing:
struct SearchScope: Equatable {
var visitedOnly: Bool = false
var beginsWith: String = ""
var searchBy: SearchByField = .name
}
It also generates the predicate for the fetch request based on the search scope state:
var predicate: NSPredicate? {
switch (beginsWith.isBlank, visitedOnly) {
case (true, false): return nil
case (true, true): return Country.visitedPredicate
case (false, false): return searchPredicate()
case (false, true): return NSCompoundPredicate(
andPredicateWithSubpredicates:
[Country.visitedPredicate, searchPredicate()])
}
}
The predicate definitions are mostly statically defined in my model:
extension Country: ManagedObject {
static var visitedPredicate: NSPredicate {
NSPredicate(format: "%K == true", #keyPath(visited))
}
static var searchByNamePredicate: NSPredicate {
NSPredicate(format: "%K BEGINSWITH[cd] $query", #keyPath(name))
}
static var searchByCapitalPredicate: NSPredicate {
NSPredicate(format: "%K BEGINSWITH[cd] $query", #keyPath(capital))
}
}
The only complication is the search predicate which substitutes the search query and handles searching by name or capital:
private func searchPredicate() -> NSPredicate {
let template: NSPredicate
switch searchBy {
case .name: template = Country.searchByNamePredicate
case .capital: template = Country.searchByCapitalPredicate
}
return template.withSubstitutionVariables(["query": beginsWith])
}
My view then stores the search scope as a @State
variable:
@State private var scope = SearchScope()
A scope bar view provides the user interface and updates the search scope:
VStack {
CountryScope(scope: $scope)
.padding()
CountryList(countries: countries)
}
That just leaves us to update the fetch request when the search scope changes.
Updating The Fetch Request
An onChange
view modifier allows us to update the fetch request predicate anytime the scope state changes:
NavigationView { ...
}
.onChange(of: scope) { newValue in
countries.nsPredicate = scope.predicate
}
Updating the fetch request updates the view with the new results. In my example, I only need to update the predicate of the fetch request. Updating the sort descriptor would work in the same way:
countries.sortDescriptors = scope.sortDescriptors
Here’s the final version of the view:
struct WorldView: View {
@State private var scope = SearchScope()
@FetchRequest(sortDescriptors: [SortDescriptor(\.name, order: .forward)])
private var countries: FetchedResults<Country>
var body: some View {
NavigationView {
VStack {
CountryScope(scope: $scope)
.padding()
CountryList(countries: countries)
}
.navigationTitle("World")
NoCountrySelected()
}
.onChange(of: scope) { newValue in
countries.nsPredicate = newValue.predicate
}
}
}