Getting started with NSFetchedResultsController iOS 05.05.2020

Introduction

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.

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

Let's create NSFetchedResultsController.

guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
let managedContext = appDelegate.persistentContainer.viewContext

lazy var fetchedResultsController: NSFetchedResultsController<Item> = {
    let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()

    let sort = NSSortDescriptor(key: #keyPath(Item.price), ascending: true)
    fetchRequest.sortDescriptors = [sort]

    //let predicate = NSPredicate(format: "price == 5")
    //fetchRequest.predicate = predicate

    let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
        managedObjectContext: managedContext, 
        sectionNameKeyPath: nil,
        cacheName: nil)
    return fetchedResultsController
}()

Like NSFetchRequest, NSFetchedResultsController requires a generic type parameter, Item in this case, to specify the type of entity you expect to be working with.

Next, add the following code to the end of viewDidLoad() to actually do the fetching:

do {
    try fetchedResultsController.performFetch()
    tableView.reloadData()
} catch let error as NSError {
    print("Fetching error: \(error), \(error.userInfo)") 
}

NSFetchedResultsController is both a wrapper around a fetch request and a container for its fetched results. You can get them either with the fetchedObjects property or the object(at:) method.

Next, you’ll connect the fetched results controller to the usual table view data source methods. The fetched results determine both the number of sections and the number of rows per section.

With this in mind, reimplement numberOfSections(in:) and tableView(_:numberOfRowsInSection:), as shown below:

func numberOfSections(in tableView: UITableView) -> Int {
    return fetchedResultsController.sections?.count ?? 0 
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 
    // return fetchedResultsController.fetchedObjects?.count ?? 0
    guard let sectionInfo = fetchedResultsController.sections?[section] else { return 0 }
    return sectionInfo.numberOfObjects 
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let item = fetchedResultsController.object(at: indexPath) 
    item.price += 1
    saveContext()
    tableView.reloadData()
}

You will only have one section this time around, but keep in mind that NSFetchedResultsController can split up your data into sections.

Implementing tableView(_:cellForRowAt:) would typically be the next step.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") else { return UITableViewCell() }
    let item = fetchedResultsController.object(at: indexPath) 
    cell.textLabel?.text = item.title
    return cell
} 

A quick look at the method, however, reveals it’s already vending ItemCell cells as necessary. What you need to change is the helper method that populates the cell. Find configure(cell:for:) and replace it with the following:

Grouping results into sections

In this section, you’ll split up the list of items into their categories. NSFetchedResultsController makes this very simple. Let’s see it in action. Go back to the lazy property that instantiates your NSFetchedResultsController and make the following change to the fetched results controller’s initializer:

let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
    managedObjectContext: managedContext, 
    sectionNameKeyPath: #keyPath(Item.category), 
    cacheName: nil)

The difference here is you’re passing in a value for the optional sectionNameKeyPath parameter. You can use this parameter to specify an attribute the fetched results controller should use to group the results and generate sections.

How exactly are these sections generated? Each unique attribute value becomes a section. NSFetchedResultsController then groups its fetched results into these sections.

The fetched results controller will now report the sections and rows to the table view, but the current UI won’t look any different. To fix this problem, add the following method to the UITableViewDataSource extension:

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    let sectionInfo = fetchedResultsController.sections?[section] 
    return sectionInfo?.name
}

Monitoring changes

Earlier, when we implemented the tap to increment the price, we added a line of code to reload the table view to show the updated price. This was a brute force solution, but it worked. Sure, you could have reloaded only the selected cell by being smart about the UITableView API, but that wouldn’t have solved the root problem.

NSFetchedResultsController can listen for changes in its result set and notify its delegate, NSFetchedResultsControllerDelegate. You can use this delegate to refresh the table view as needed any time the underlying data changes.

Add the following extension to the bottom of the file:

extension ViewController: NSFetchedResultsControllerDelegate { }

This simply tells the compiler the ViewController class will implement some of the fetched results controller’s delegate methods. Next, go back to your lazy NSFetchedResultsController property and set the view controller as the fetched results controller’s delegate before returning. Add the following line of code after you initialize the fetched results controller:

fetchedResultsController.delegate = self

First, remove the reloadData() call from tableView(_:didSelectRowAt:).

NSFetchedResultsControllerDelegate has four methods that come in varying degrees of granularity.

Add the following method inside the NSFetchedResultsControllerDelegate extension:

//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! ItemCell
            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() 
}

There is one more NSFetchedResultsControllerDelegate method to explore. Add it to the extension:

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

This delegate method is similar to controllerDidChangeContent(_:) but notifies you of changes to sections rather than to individual objects. Here, you handle the cases where changes in the underlying data trigger the creation or deletion of an entire section.