Configuring SwiftUI Fetch Requests

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:

World navigation view showing a list of countries

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

Search query matching visited countries with capital starting with L

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
    }
  }
}

Learn More