Even though you don't need to use the NSFetchedResultsController
class to populate a table view, I find myself using it quite a bit in Core Data applications. Swift has substantially improved support for Core Data and the NSFetchedResultsController
class also benefits from these optimizations.
In this tutorial, we create an application that manages a list of quotes. We populate a table view with quotes using the NSFetchedResultsController
class. The tools we use are Swift 4 and Xcode 9.
Setting Up the Project
Create a new project in Xcode and choose the Single View Application template. Name the project Quotes and make sure the Use Core Data checkbox is unchecked. We won't be using Xcode's Core Data template.
Adding the Data Model
Create a new file and choose the Data Model template in the iOS > Core Data section.
Name the data model Quotes and click Create.
Creating the Quote Entity
Open Quotes.xcdatamodeld and add an entity, Quote. Add three attributes:
- author of type String
- contents of type String
- createdAt of type Double
Creating the User Interface
As I mentioned in the introduction, we display the quotes in a table view. Open ViewController.swift and declare three outlets:
messageLabel
of typeUILabel!
tableView
of typeUITableView!
activityIndicatorView
of typeUIActivityIndicatorView!
import UIKit
class ViewController: UIViewController {
// MARK: - Properties
@IBOutlet var messageLabel: UILabel!
@IBOutlet var tableView: UITableView!
@IBOutlet var activityIndicatorView: UIActivityIndicatorView!
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
Open Main.storyboard and embed the view controller of the View Controller Scene in a navigation controller. Add a table view, a label, and an activity indicator view to the view of the view controller. Don't forget to connect the user interface elements with the outlets we declared a moment ago. And make the view controller the delegate and the data source of the table view by connecting the delegate
and dataSource
outlets of the table view.
Create a new UITableViewCell
subclass and name it QuoteTableViewCell
.
In QuoteTableViewCell.swift, declare two outlets:
authorLabel
of typeUILabel!
contentsLabel
of typeUILabel!
The class also defines a static constant, reuseIdentifier
, which we use as the reuse identifier of the table view cell.
import UIKit
class QuoteTableViewCell: UITableViewCell {
// MARK: - Properties
static let reuseIdentifier = "QuoteCell"
// MARK: -
@IBOutlet var authorLabel: UILabel!
@IBOutlet var contentsLabel: UILabel!
// MARK: - Initialization
override func awakeFromNib() {
super.awakeFromNib()
}
}
Revisit Main.storyboard, select the table view, and set Prototype Cells to 1. Select the prototype cell, open the Identity Inspector on the right, and set Class to QuoteTableViewCell.
Add two labels to the content view of the table view cell, position them as shown below, add the necessary constraints, and don't forget to connect the labels to the outlets of the QuoteTableViewCell
class.
Last but not least, select the prototype cell and, in the Attributes Inspector on the right, set Identifier to QuoteCell.
Populating the Table View
To populate the table view, we need to implement the UITableViewDataSource
protocol. Open ViewController.swift and define a property to store the quotes. This property is only temporary. The fetched results controller is going to be in charge of managing the data for the table view.
var quotes = [Quote]()
Create an extension for the ViewController
class and implement the UITableViewDataSource
protocol as shown below.
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return quotes.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: QuoteTableViewCell.reuseIdentifier, for: indexPath) as? QuoteTableViewCell else {
fatalError("Unexpected Index Path")
}
// Fetch Quote
let quote = quotes[indexPath.row]
// Configure Cell
cell.authorLabel.text = quote.author
cell.contentsLabel.text = quote.contents
return cell
}
}
If you don't use a NSFetchedResultsController
instance to manage the data of the table view, you need to make sure the table view is updated when the data changes. For example, we want to hide the table view if the application doesn't contain any quotes. Define a didSet
property observer for the quotes
property in which we invoke a helper method, updateView()
.
var quotes = [Quote]() {
didSet {
updateView()
}
}
The implementation of updateView()
is straightforward as you can see below.
private func updateView() {
let hasQuotes = quotes.count > 0
tableView.isHidden = !hasQuotes
messageLabel.isHidden = hasQuotes
}
We need to add a few more details. In viewDidLoad()
, we invoke another helper method, setupView()
.
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
In this helper method, we invoke setupMessageLabel()
and updateView()
.
private func setupView() {
setupMessageLabel()
updateView()
}
// MARK: -
private func setupMessageLabel() {
messageLabel.text = "You don't have any quotes yet."
}
Build and run the application to see what we have so far. You should see the message label being displayed. The activity indicator view is also visible. That is the next thing we fix.
Setting Up the Core Data Stack
We are building a Core Data application and we don't have a Core Data stack yet. What is that about? Open ViewController.swift and declare a property of type NSPersistentContainer
. We initialize the persistent container by passing in the name of the data model we created earlier. Don't forget to add an import statement for the Core Data framework at the top.
If the NSPersistentContainer
container class is new to you, then you may want to read more about that first.
import UIKit
import CoreData
class ViewController: UIViewController {
...
private let persistentContainer = NSPersistentContainer(name: "Quotes")
...
}
Even though we have created a NSPersistentContainer
instance, the Core Data stack is not ready for us to use. We need to add the persistent store to the persistent store coordinator. We do this in the viewDidLoad()
method as shown below.
override func viewDidLoad() {
super.viewDidLoad()
persistentContainer.loadPersistentStores { (persistentStoreDescription, error) in
if let error = error {
print("Unable to Load Persistent Store")
print("\(error), \(error.localizedDescription)")
} else {
self.setupView()
}
}
}
The loadPersistentStores(completionHandler:)
method asynchronously loads the persistent store(s) and adds it to the persistent store coordinator. When this operation finishes, the completion handler of the loadPersistentStores(completionHandler:)
method is invoked. Even though the persistent store is added to the persistent store coordinator on a background queue, the completion handler is always invoked on the calling thread.
In the completion handler, we make sure no errors popped up. If everything went smoothly, we invoke setupView()
. Before we can run the application again to see the result, we need to revisit the main storyboard.
Select the activity indicator view and, in the Attributes Inspector on the right, check the Animating and Hides When Stopped checkboxes.
Select the table view and the message label and, in the Attributes Inspector on the right, check the Hidden checkbox.
In the updateView()
method of the ViewController
class, we hide the activity indicator view by invoking stopAnimating()
.
private func updateView() {
let hasQuotes = quotes.count > 0
tableView.isHidden = !hasQuotes
messageLabel.isHidden = hasQuotes
activityIndicatorView.stopAnimating()
}
Creating the Fetched Results Controller
If we don't use a fetched results controller, we need to manually fetch and manage the quotes displayed in the table view. The NSFetchedResultsController
class makes this much easier. We first need to create a lazy property for the fetched results controller.
Notice that we specify the type of objects the fetched results controller manages. Core Data and Swift work very well together. It makes working with NSManagedObject
subclasses much easier.
fileprivate lazy var fetchedResultsController: NSFetchedResultsController<Quote> = {
// Create Fetch Request
let fetchRequest: NSFetchRequest<Quote> = Quote.fetchRequest()
// Configure Fetch Request
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
// Create Fetched Results Controller
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
// Configure Fetched Results Controller
fetchedResultsController.delegate = self
return fetchedResultsController
}()
To create an instance of the NSFetchedResultsController
class, we need a fetch request. We ask the Quote
class for a NSFetchRequest<Quote>
object. We configure the fetch request by setting its sortDescriptors
property. We sort the quotes based on the value of the createdAt
property.
We then initialize the NSFetchedResultsController
instance. The initializer defines four parameters:
- a fetch request
- a managed object context
- a key path for creating sections
- a cache name for optimizing performance
The managed object context we pass to the initializer is the managed object context that is 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 the delegate to self
, the view controller.
Because the fetchedResultsController
property is a lazy property, it isn't a problem that we access the viewContext
property of the persistent container. But this means that we need to make sure we access the fetchedResultsController
property after the Core Data stack is fully initialized.
Performing a Fetch
The fetched results controller doesn't perform a fetch if we don't tell it to. We should perform a fetch when the Core Data stack is ready to use. Performing a fetch is as simple as invoking performFetch()
on the fetched results controller. We do this in the completion handler of the loadPersistentStores(completionHandler:)
method.
override func viewDidLoad() {
super.viewDidLoad()
persistentContainer.loadPersistentStores { (persistentStoreDescription, error) in
if let error = error {
print("Unable to Load Persistent Store")
print("\(error), \(error.localizedDescription)")
} else {
self.setupView()
do {
try self.fetchedResultsController.performFetch()
} catch {
let fetchError = error as NSError
print("Unable to Perform Fetch Request")
print("\(fetchError), \(fetchError.localizedDescription)")
}
self.updateView()
}
}
}
Notice that we also invoke updateView
at the end of the completion handler to update the view.
We can now get rid of the quotes
property and ask the fetched results controller for the data we need. Remove the quotes
property and update the implementation of the UITableViewDataSource
protocol as shown below.
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let quotes = fetchedResultsController.fetchedObjects else { return 0 }
return quotes.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: QuoteTableViewCell.reuseIdentifier, for: indexPath) as? QuoteTableViewCell else {
fatalError("Unexpected Index Path")
}
// Fetch Quote
let quote = fetchedResultsController.object(at: indexPath)
// Configure Cell
cell.authorLabel.text = quote.author
cell.contentsLabel.text = quote.contents
return cell
}
}
To obtain the number of quotes the fetched results controller fetched from the persistent store, we ask it for the value of its fetchedObjects
property. If it has any managed objects for us, we return the number.
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let quotes = fetchedResultsController.fetchedObjects else { return 0 }
return quotes.count
}
A fetched results controller is perfectly suited for managing the contents of a table view. This means it has no problem working with sections and rows. If we ask it for the managed object at a particular index path, it knows exactly what we need. This means we only need to update one line in the tableView(_:cellForRowAt:)
method.
// Fetch Quote
let quote = fetchedResultsController.object(at: indexPath)
Before we can build and run the application, we need to update the updateView()
method.
private func updateView() {
var hasQuotes = false
if let quotes = fetchedResultsController.fetchedObjects {
hasQuotes = quotes.count > 0
}
tableView.isHidden = !hasQuotes
messageLabel.isHidden = hasQuotes
activityIndicatorView.stopAnimating()
}
And we need to create an extension for the ViewController
class to make it conform to the NSFetchedResultsControllerDelegate
protocol.
extension ViewController: NSFetchedResultsControllerDelegate {}
Because every method of the NSFetchedResultsControllerDelegate
protocol is optional, we can leave the extension empty for now. We implement the methods of the protocol later.
Build and Run
Build and run the application. The user interface hasn't changed much, but it is now powered by a fetched results controller.
In the next tutorial, we implement the methods of the NSFetchedResultsControllerDelegate
protocol. This will enable us to update the table view if quotes are added, updated, or removed.