The application now supports creating, reading, updating, and deleting notes. And this works fine. But Core Data has another trick up its sleeve.
On iOS and macOS, data fetched from the persistent store is very often displayed in a table or collection view. Because this is such a common pattern, the Core Data framework includes a class that's specialized in managing the results of a fetch request and providing the data needed to populate a table or collection view. This class is the NSFetchedResultsController class.
In this episode, we refactor the notes view controller. We use a NSFetchedResultsController instance to populate the table view of the notes view controller.
Creating a Fetched Results Controller
Open NotesViewController.swift and declare a lazy property, fetchedResultsController, of type NSFetchedResultsController. Notice that we specify the type of objects the fetched results controller will manage. Core Data and Swift work very well together. It makes working with managed objects much easier. Using Core Data with Swift 1 and 2 was much less elegant.
NotesViewController.swift
private lazy var fetchedResultsController: NSFetchedResultsController<Note> = {
}()
To create an instance of the NSFetchedResultsController class, we need a fetch request. The fetch request is identical to the one we created in the fetchNotes() method.
NotesViewController.swift
// Create Fetch Request
let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
We ask the Note class for a NSFetchRequest<Note> instance and we configure it by setting its sortDescriptors property. We sort the notes based on the value of the updatedAt property like we did earlier in the fetchNotes() method.
NotesViewController.swift
// Configure Fetch Request
fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(Note.updatedAt), ascending: false)]
We then initialize the NSFetchedResultsController instance by invoking the designated initializer, init(fetchRequest:managedObjectContext:sectionNameKeyPath:cacheName:). The initializer defines four parameters:
- a fetch request
- a managed object context
- a key path for creating sections
- and a cache name for optimizing performance
NotesViewController.swift
// Create Fetched Results Controller
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: self.coreDataManager.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil)
The managed object context we pass to the initializer is the managed object context that's used to perform the fetch request. The key path and cache name are not important for this discussion.
Before we return the fetched results controller from the closure, we set its delegate to self, the view controller.
NotesViewController.swift
// Configure Fetched Results Controller
fetchedResultsController.delegate = self
Because the fetchedResultsController property is a lazy property, it isn't a problem that we access the managedObjectContext property of the Core Data manager. But this means that we need to make sure we access the fetchedResultsController property after the Core Data stack is fully initialized.
This is the implementation of the fetchedResultsController property.
NotesViewController.swift
private lazy var fetchedResultsController: NSFetchedResultsController<Note> = {
// Create Fetch Request
let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
// Configure Fetch Request
fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(Note.updatedAt), ascending: false)]
// Create Fetched Results Controller
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: self.coreDataManager.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil)
// Configure Fetched Results Controller
fetchedResultsController.delegate = self
return fetchedResultsController
}()
Performing a Fetch Request
The fetched results controller doesn't perform a fetch if we don't tell it to. Performing a fetch is as simple as invoking performFetch() on the fetched results controller. We do this in the fetchNotes() method.
NotesViewController.swift
private func fetchNotes() {
do {
try fetchedResultsController.performFetch()
} catch {
print("Unable to Perform Fetch Request")
print("\(error), \(error.localizedDescription)")
}
}
We remove the current implementation of the fetchNotes() method and invoke performFetch() on the fetched results controller instead. Because the performFetch() method is throwing, we wrap it in a do-catch statement.
To make sure the user interface is updated after performing the fetch request in viewDidLoad(), we invoke updateView() at the end of the viewDidLoad() method.
NotesViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
title = "Notes"
setupView()
fetchNotes()
setupNotificationHandling()
updateView()
}
Because the fetched results controller now manages the notes fetched from the persistent store, we can get rid of the notes property and ask the fetched results controller for the data we need.
This also means that the implementation of the hasNotes property needs some changes. We ask the fetched results controller for the value of its fetchedObjects property, the managed objects it fetched from the persistent store.
NotesViewController.swift
private var hasNotes: Bool {
guard let fetchedObjects = fetchedResultsController.fetchedObjects else { return false }
return fetchedObjects.count > 0
}
If the value of fetchedObjects isn't equal to nil, we return true if the number of managed objects is greater than 0. As you can see, this isn't rocket science.
Updating the Table View
The most important change is related to the implementation of the UITableViewDataSource protocol. We start by optimizing the implementation of the numberOfSections(in:) method. The current implementation is fine, but the NSFetchedResultsController class offers a better solution.
A fetched results controller is capable of managing hierarchical data. That's why it's such a good fit for table and collection views. Even though we're not splitting the notes up into sections, we can still ask the fetched results controller for the sections it manages. We return the number of sections using the value of the sections property.
NotesViewController.swift
func numberOfSections(in tableView: UITableView) -> Int {
guard let sections = fetchedResultsController.sections else { return 0 }
return sections.count
}
In tableView(_:numberOfRowsInSection:), we ask the fetched results controller for the section that corresponds with the value of the section parameter. The object that's returned to us conforms to the NSFetchedResultsSectionInfo protocol. The numberOfObjects property tells us exactly how many managed objects the section contains.
NotesViewController.swift
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let section = fetchedResultsController.sections?[section] else { return 0 }
return section.numberOfObjects
}
The only change we need to make to the tableView(_:cellForRowAt:) method is how we fetch the note that corresponds with the value of the indexPath parameter. As I mentioned earlier, the NSFetchedResultsController class was designed with table views in mind (collection views were introduced several years later).
To fetch the note, we ask the fetched results controller for the managed object that corresponds with the index path. As you can see, the fetched results controller knows how to handle hierarchical data.
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")
}
// Fetch Note
let note = fetchedResultsController.object(at: indexPath)
...
return cell
}
The last method we need to update is tableView(_:commit:forRowAt:). We apply the same strategy to fetch the note that needs to be deleted.
NotesViewController.swift
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
// Fetch Note
let note = fetchedResultsController.object(at: indexPath)
// Delete Note
coreDataManager.managedObjectContext.delete(note)
}
A Few More Changes
Before we can run the application, we need to make three more changes.
First, we need to update the implementation of prepare(for:sender:). We ask the fetched results controller for the note that corresponds with the currently selected row of the table view.
NotesViewController.swift
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
switch identifier {
case Segue.AddNote:
...
case Segue.Note:
guard let destination = segue.destination as? NoteViewController else {
return
}
guard let indexPath = tableView.indexPathForSelectedRow else {
return
}
// Fetch Note
let note = fetchedResultsController.object(at: indexPath)
// Configure Destination
destination.note = note
default:
break
}
}
Second, we can get rid of setupNotificationHandling() and managedObjectContextObjectsDidChange(_:). We no longer need these methods because the fetched results controller observes the managed object context for us. Remove these methods and any references to them.
Third, the view controller is the delegate of the fetched results controller. This means it needs to conform to the NSFetchedResultsControllerDelegate protocol. To satisfy the compiler we create an empty extension for the NotesViewController class in which it conforms to the NSFetchedResultsControllerDelegate protocol. This isn't a problem because every method of the protocol is optional. The NSFetchedResultsControllerDelegate protocol is an Objective-C protocol, which support optional methods.
NotesViewController.swift
extension NotesViewController: NSFetchedResultsControllerDelegate {
}
Run the application to see the result. Everything seems to work fine ... well ... more or less. If we create, update, or delete a note, the table view isn't updated. For that to work, we need to implement the NSFetchedResultsControllerDelegate protocol. We do that in the next episode.