To update the table view, we listened for notifications sent by the managed object context of the Core Data manager. This is a perfectly fine solution. But it can be messy to sift through the managed objects contained in the userInfo dictionary of the notification. In a complex Core Data application, the NSManagedObjectContextObjectsDidChange notification is sent very frequently. It includes every change of every managed object, even the ones we may not be interested in. We need to make sure we only respond to the changes of the managed objects we are interested in.
The NSFetchedResultsController class makes this much easier. Well ... the NSFetchedResultsControllerDelegate protocol does. It exposes four methods that make it very easy to update a table or collection view when it's appropriate.
The most important benefit of using a fetched results controller is that it takes care of observing the managed object context it's tied to and it only notifies its delegate when it's appropriate to update the user interface.
This is a very powerful concept. We hand the fetched results controller a fetch request and the fetched results controller makes sure its delegate is only notified when the results of that fetch request change. What's not to like about that?
Implementing the Protocol
The NSFetchedResultsControllerDelegate protocol defines four methods. We need to implement three of those in the extension for the NotesViewController class. The first two methods are easy:
controllerWillChangeContent(_:)- and
controllerDidChangeContent(_:)
The controllerWillChangeContent(_:) method is invoked when the fetched results controller starts processing one or more inserts, updates, or deletes. The controllerDidChangeContent(_:) method is invoked when the fetched results controller has finished processing the changes.
The implementation of these methods is surprisingly short. In controllerWillChangeContent(_:) we inform the table view that updates are on their way.
NotesViewController.swift
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
And in controllerDidChangeContent(_:), we notify the table view that we won't be sending it any more updates. This is important since multiple updates can occur in a very short time frame. By notifying the table view, we can batch update the table view. This is more efficient and performant.
In controllerDidChangeContent(_:), we also invoke updateView(). This is necessary because we need to show the message label when the last note is deleted and we need to show the table view when the first note is inserted.
NotesViewController.swift
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
updateView()
}
More interesting is the implementation of the controller(_:didChange:at:for:newIndexPath:) method. That's quite a mouthful. As the name of the method implies, this method is invoked every time a managed object is modified. As you can imagine, it's possible that this method is invoked several times for a single change made by the user.
NotesViewController.swift
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
}
The type parameter can have four possible values:
insertdeleteupdate- and
move
The move type shows once more that the NSFetchedResultsController class is a perfect fit for table and collection views.
NotesViewController.swift
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch (type) {
case .insert:
case .delete:
case .update:
case .move:
}
}
Inserts
Let's start with inserts. The method gives us the destination of the managed object that was inserted. This destination is stored in the newIndexPath parameter. We unwrap the value of newIndexPath and insert a row at the correct index path. The rest is taken care of by the implementation of the UITableViewDataSource protocol.
NotesViewController.swift
case .insert:
if let indexPath = newIndexPath {
tableView.insertRows(at: [indexPath], with: .fade)
}
Deletes
Deletes are just as easy. The index path of the deleted managed object is stored in the indexPath parameter. We safely unwrap the value of indexPath and delete the corresponding row from the table view.
NotesViewController.swift
case .delete:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath], with: .fade)
}
Updates
For updates, we don't need to make changes to the table view itself. But we need to fetch the table view cell that corresponds with the updated managed object and update its contents.
For that, we need a helper method, configure(_:at:). We can reuse some of the components of the tableView(_:cellForRowAt:) method of the UITableViewDataSource protocol. We fetch the managed object that corresponds with the value of indexPath and we update the table view cell.
NotesViewController.swift
func configure(_ cell: NoteTableViewCell, at indexPath: IndexPath) {
// Fetch Note
let note = fetchedResultsController.object(at: indexPath)
// Configure Cell
cell.titleLabel.text = note.title
cell.contentsLabel.text = note.contents
cell.updatedAtLabel.text = updatedAtDateFormatter.string(from: note.updatedAtAsDate)
}
This also means we can simplify the implementation of tableView(_:cellForRowAt:).
NotesViewController.swift
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// Dequeue Reusable Cell
guard let cell = tableView.dequeueReusableCell(withIdentifier: NoteTableViewCell.reuseIdentifier, for: indexPath) as? NoteTableViewCell else {
fatalError("Unexpected Index Path")
}
// Configure Cell
configure(cell, at: indexPath)
return cell
}
We can now use this helper method to update the table view cell that corresponds with the managed object that was updated. We safely unwrap the value of indexPath, fetch the table view cell that corresponds with the index path, and use the configure(_:at:) method to update the contents of the table view cell.
NotesViewController.swift
case .update:
if let indexPath = indexPath, let cell = tableView.cellForRow(at: indexPath) as? NoteTableViewCell {
configure(cell, at: indexPath)
}
Moves
When a managed object is modified, it can impact the sort order of the managed objects. This isn't easy to implement from scratch. Fortunately, the fetched results controller takes care of this as well through the move type.
The implementation is straightforward. The value of the indexPath parameter represents the original position of the managed object and the value of the newIndexPath parameter represents the new position of the managed object. This means that we need to delete the row at the old position and insert a row at the new position.
NotesViewController.swift
case .move:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath], with: .fade)
}
if let newIndexPath = newIndexPath {
tableView.insertRows(at: [newIndexPath], with: .fade)
}
Believe it or not, that's all we need to do to respond to changes using a fetched results controller. Run the application again and make some changes to see the result.
Notice that we now also have animations. This is something we didn't have with the previous implementation because we reloaded the table view with every change.
The NSFetchedResultsController class is a great addition to the Core Data framework. Even though it isn't required in a Core Data application, I'm sure you agree that it can be incredibly useful. As of macOS 10.12, the NSFetchedResultsController class is also available on the macOS platform.