Chapters

Hide chapters

Core Data by Tutorials

Seventh Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 14 chapters
Show chapters Hide chapters

5. NSFetchedResultsController
Written by Pietro Rea

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

If you followed the previous chapters closely, you probably noticed that most of the sample projects use table views. That’s because Core Data fits nicely with table views. Set up your fetch request, fetch an array of managed objects and plug the result into the table view’s data source. This is a common, everyday scenario.

If you see a tight relationship between Core Data and UITableView, you’re in good company. The authors of the Core Data framework at Apple thought the same way! In fact, they saw so much potential for a close connection between UITableView and Core Data they penned a class to formalize this bond: NSFetchedResultsController.

As the name suggests, NSFetchedResultsController is a controller, but it’s not a view controller. It has no user interface. Its purpose is to make developers’ lives easier by abstracting away much of the code needed to synchronize a table view with a data source backed by Core Data.

Set up an NSFetchedResultsController correctly, and your table will “magically” mimic its data source without you have to write more than a few lines of code. In this chapter, you’ll learn the ins and outs of this class. You’ll also learn when to use it and when not to use it. Are you ready?

Introducing the World Cup app

This chapter’s sample project is a World Cup scoreboard app for iOS. On startup, the one-page application will list all the teams contesting for the World Cup. Tapping on a country’s cell will increase the country’s wins by one. In this simplified version of the World Cup, the country with the most taps wins the tournament. This ranking simplifies the real elimination rules quite a bit, but it’s good enough for demonstration purposes.

Go to this chapter’s files and find the starter folder. Open WorldCup.xcodeproj. Build and run the starter project:

The sample application consists of 20 static cells in a table view. Those bright blue boxes are where the teams’ flags should be. Instead of real names, you see “Team Name.“ Although the sample project isn’t too exciting, it actually does a lot of the setup for you.

Open the project navigator and take a look at the full list of files in the starter project:

Before jumping into the code, let’s briefly go over what each class does for you out of the box. You’ll find a lot of the setup you did manually in previous chapters comes already implemented for you. Hooray!

  • CoreDataStack: As in previous chapters, this object wraps an instance of NSPersistentContainer, which in turn contains the cadre of Core Data objects known as the “stack”: the context, the model, the persistent store and the persistent store coordinator. No need to set this up. It comes ready-to-use.

  • ViewController: The sample project is a one-page application, and this file represents that one page. On first launch, the view controller reads from seed.json, creates corresponding Core Data objects and saves them to the persistent store. If you’re curious about its UI elements, head over to Main.storyboard. There’s a table, a navigation bar and a single prototype cell.

  • Team+CoreDataClass & Team+CoreDataProperties: These files represent a country’s team. It’s an NSManagedObject subclass with properties for each of its four attributes: teamName, qualifyingZone, imageName and wins. If you’re curious about its entity definition, head over to WorldCup.xcdatamodel.

  • Assets.xcassets: The sample project’s asset catalog contains a flag image for every country in seed.json.

The first three chapters of this book covered the Core Data concepts mentioned above. If “managed object subclass” doesn’t ring a bell or if you’re unsure what a Core Data stack is supposed to do, you may want to go back and reread the relevant chapters. NSFetchedResultsController will be here when you return.

Otherwise, if you’re ready to proceed, you’ll begin implementing the World Cup application. You probably already know who won the World Cup last time, but this is your chance to rewrite history for the country of your choice, with just a few taps!

It all begins with a fetch request…

At its core, NSFetchedResultsController is a wrapper around the results of an NSFetchRequest. Right now, the sample project contains static information. You’re going to create a fetched results controller to display the list of teams from Core Data in the table view.

lazy var fetchedResultsController:
  NSFetchedResultsController<Team> = {
  // 1
  let fetchRequest: NSFetchRequest<Team> = Team.fetchRequest()

  // 2
  let fetchedResultsController = NSFetchedResultsController(
    fetchRequest: fetchRequest,
    managedObjectContext: coreDataStack.managedContext,
    sectionNameKeyPath: nil,
    cacheName: nil)

  return fetchedResultsController
}()
do {
  try fetchedResultsController.performFetch()
} catch let error as NSError {
  print("Fetching error: \(error), \(error.userInfo)")
}
func numberOfSections(in tableView: UITableView) -> Int {

  return fetchedResultsController.sections?.count ?? 0
}

func tableView(_ tableView: UITableView,
               numberOfRowsInSection section: Int)
               -> Int {

  guard let sectionInfo = 
    fetchedResultsController.sections?[section] else {
      return 0
  }

  return sectionInfo.numberOfObjects
}
func configure(cell: UITableViewCell,
               for indexPath: IndexPath) {

  guard let cell = cell as? TeamCell else {
      return
  }

  let team = fetchedResultsController.object(at: indexPath)
  cell.teamLabel.text = team.teamName
  cell.scoreLabel.text = "Wins: \(team.wins)"

  if let imageName = team.imageName {
    cell.flagImageView.image = UIImage(named: imageName)
  } else {
    cell.flagImageView.image = nil
  }
}
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'An instance of NSFetchedResultsController requires a fetch request with sort descriptors'
*** First throw call stack:
(
	0   CoreFoundation                      0x00007fff23b60e3e __exceptionPreprocess + 350
	1   libobjc.A.dylib                     0x00007fff502f3b20 objc_exception_throw + 48
	2   CoreData                            0x00007fff238a147d -[NSFetchedResultsController dealloc] + 0 
	--- snip! ---
	30  libdyld.dylib                       0x00007fff51175cf5 start + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException
'An instance of NSFetchedResultsController requires a fetch request with sort descriptors'
let sort = NSSortDescriptor(key: #keyPath(Team.teamName),
                            ascending: true)
fetchRequest.sortDescriptors = [sort]

Modifying data

Let’s fix everyone’s zero score and add some code to increment the number of wins. Still in ViewController.swift, replace the currently empty implementation of the table view delegate method tableView(_:didSelectRowAt:) with the following:

func tableView(_ tableView: UITableView,
               didSelectRowAt indexPath: IndexPath) {

  let team = fetchedResultsController.object(at: indexPath)
  team.wins = team.wins + 1
  coreDataStack.saveContext()
}

tableView.reloadData()

Grouping results into sections

There are six qualifying zones in the World Cup: Africa, Asia, Oceania, Europe, South America and North/Central America. The Team entity has a string attribute named qualifyingZone storing this information.

let fetchedResultsController = NSFetchedResultsController(
  fetchRequest: fetchRequest,
  managedObjectContext: coreDataStack.managedContext,
  sectionNameKeyPath: #keyPath(Team.qualifyingZone),
  cacheName: nil)
func tableView(_ tableView: UITableView,
               titleForHeaderInSection section: Int)
               -> String? {
  let sectionInfo = fetchedResultsController.sections?[section]
  return sectionInfo?.name
}

let zoneSort = NSSortDescriptor(
  key: #keyPath(Team.qualifyingZone), ascending: true)
let scoreSort = NSSortDescriptor(
  key: #keyPath(Team.wins), ascending: false)
let nameSort = NSSortDescriptor(
  key: #keyPath(Team.teamName), ascending: true)

fetchRequest.sortDescriptors = [zoneSort, scoreSort, nameSort]

“Cache” the ball

As you can probably imagine, grouping teams into sections is not a cheap operation. There’s no way to avoid iterating over every team.

let fetchedResultsController = NSFetchedResultsController(
  fetchRequest: fetchRequest,
  managedObjectContext: coreDataStack.managedContext,
  sectionNameKeyPath: #keyPath(Team.qualifyingZone),
  cacheName: "worldCup")

Monitoring changes

This chapter has already covered two of the three main benefits of using NSFetchedResultsController: sections and caching. The third and last benefit is somewhat of a double-edged sword: it’s powerful but also easy to misuse.

// MARK: - NSFetchedResultsControllerDelegate
extension ViewController: NSFetchedResultsControllerDelegate {

}
fetchedResultsController.delegate = self

Responding to changes

First, remove the reloadData() call from tableView(_:didSelectRowAt:). As mentioned before, this was the brute force approach that you’re now going to replace.

func controllerDidChangeContent(_ controller: 
  NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.reloadData()
}

func controllerWillChangeContent(_ controller:
  NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.beginUpdates()
}

func controller(_ controller:
  NSFetchedResultsController<NSFetchRequestResult>,
  didChange anObject: Any,
  at indexPath: IndexPath?,
  for type: NSFetchedResultsChangeType,
  newIndexPath: IndexPath?) {
  
  switch type {
  case .insert:
    tableView.insertRows(at: [newIndexPath!], with: .automatic)
  case .delete:
    tableView.deleteRows(at: [indexPath!], with: .automatic)
  case .update:
    let cell = tableView.cellForRow(at: indexPath!) as! TeamCell
    configure(cell: cell, for: indexPath!)
  case .move:
    tableView.deleteRows(at: [indexPath!], with: .automatic)
    tableView.insertRows(at: [newIndexPath!], with: .automatic)
  @unknown default:
    print("Unexpected NSFetchedResultsChangeType")
  }
}

func controllerDidChangeContent(_ controller:
  NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.endUpdates()
}

func controller(_ controller: 
  NSFetchedResultsController<NSFetchRequestResult>,
  didChange sectionInfo: NSFetchedResultsSectionInfo,
  atSectionIndex sectionIndex: Int,
  for type: NSFetchedResultsChangeType) {

  let indexSet = IndexSet(integer: sectionIndex)

  switch type {
  case .insert:
    tableView.insertSections(indexSet, with: .automatic)
  case .delete:
    tableView.deleteSections(indexSet, with: .automatic)
  default: break
  }
}

Inserting an underdog

For the sake of demonstrating what happens to the table view when there’s an insertion in the result set, let’s assume there is a way to add a new team.

override func motionEnded(_ motion: UIEvent.EventSubtype,
                          with event: UIEvent?) {
  if motion == .motionShake {
    addButton.isEnabled = true
  }
}
// MARK: - IBActions
extension ViewController {

  @IBAction func addTeam(_ sender: Any) {

    let alertController = UIAlertController(
      title: "Secret Team",
      message: "Add a new team",
      preferredStyle: .alert)

    alertController.addTextField { textField in
      textField.placeholder = "Team Name"
    }

    alertController.addTextField { textField in
      textField.placeholder = "Qualifying Zone"
    }

    let saveAction = UIAlertAction(title: "Save",
                                   style: .default) {
      [unowned self] action in

      guard 
        let nameTextField = alertController.textFields?.first,
        let zoneTextField = alertController.textFields?.last
        else {
          return
      }

      let team = Team(
        context: self.coreDataStack.managedContext)

      team.teamName = nameTextField.text
      team.qualifyingZone = zoneTextField.text
      team.imageName = "wenderland-flag"
      self.coreDataStack.saveContext()
    }

    alertController.addAction(saveAction)
    alertController.addAction(UIAlertAction(title: "Cancel",
                                            style: .cancel))

    present(alertController, animated: true)
  }
}

Diffable data sources

In iOS 13, Apple introduced a new way to implement table views and collection views: diffable data sources. Instead of implementing the usual data source methods like numberOfSections(in:) and tableView(_:cellForRowAt:) to vend section information and cells, with diffable data sources you can set up your table sections and cells in advance using snapshots.

var dataSource: UITableViewDiffableDataSource<String, Team>?
func setupDataSource()
  -> UITableViewDiffableDataSource<String, Team> {
    return UITableViewDiffableDataSource(tableView: tableView) {
      [unowned self] (tableView, indexPath, team)
      -> UITableViewCell? in

      let cell = tableView.dequeueReusableCell(
        withIdentifier: self.teamCellIdentifier,
        for: indexPath)
      self.configure(cell: cell, for: indexPath)
      return cell
    }
}
dataSource = setupDataSource()
func controller(
  _ controller:
  NSFetchedResultsController<NSFetchRequestResult>,
  didChangeContentWith
  snapshot: NSDiffableDataSourceSnapshotReference) {

  //1
  var diff = NSDiffableDataSourceSnapshot<String, Team>()
  snapshot.sectionIdentifiers.forEach { section in

    //2
    diff.appendSections([section as! String])

    //3
    let items = 
      snapshot.itemIdentifiersInSection(withIdentifier: section)
      .map { (objectId: Any) -> Team in
        let oid =  objectId as! NSManagedObjectID
        return controller
          .managedObjectContext
          .object(with: oid) as! Team
    }

    diff.appendItems(items, toSection: section as? String)
  }

  //4
  dataSource?.apply(diff)
}

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  UIView.performWithoutAnimation {
    do {
      try fetchedResultsController.performFetch()
    } catch let error as NSError {
      print("Fetching error: \(error), \(error.userInfo)")
    }
  }
}
func tableView(_ tableView: UITableView,
               viewForHeaderInSection section: Int) -> UIView? {

  let sectionInfo = fetchedResultsController.sections?[section]

  let titleLabel = UILabel()
  titleLabel.backgroundColor = .white
  titleLabel.text = sectionInfo?.name

  return titleLabel
}

func tableView(_ tableView: UITableView,
               heightForHeaderInSection section: Int)
  -> CGFloat {
  return 20
}

let cell = tableView.cellForRow(at: indexPath) as! TeamCell
configure(cell: cell, for: indexPath)

Key points

  • NSFetchedResultsController abstracts away most of the code needed to synchronize a table view with a Core Data store.
  • At its core, NSFetchedResultsController is a wrapper around an NSFetchRequest and a container for its fetched results.
  • A fetched results controller requires setting at least one sort descriptor on its fetch request. If you forget the sort descriptor, your app will crash.
  • You can set a fetched result’s controller sectionNameKeyPath to specify an attribute to group the results into table view sections. Each unique value corresponds to a different table view section.
  • Grouping a set of fetched results into sections is an expensive operation. Avoid having to compute sections multiple times by specifying a cache name on your fetched results controller.
  • A fetched results controller can listen for changes in its result set and notify its delegate, NSFetchedResultsControllerDelegate, to respond to these changes.
  • NSFetchedResultsControllerDelegate monitors changes in individual Core Data records (whether they were inserted, deleted or modified) as well as changes to entire sections.
  • Diffable data sources make working with fetched results controllers and table views easier.

Where to go from here?

You’ve seen how powerful and useful NSFetchedResultsController can be, and you’ve learned how well it works together with a table view. Table views are so common in iOS apps and you’ve seen first hand how the fetched results controller can save you a lot of time and code!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now