28.
The Locations Tab
Written by Matthijs Hollemans & Fahim Farook
You’ve set up the data model and given the app the ability to save new locations to the data store. Next, you’ll show these saved locations in a table view in the second tab.
The completed Locations screen will look like this:
This chapter covers the following:
- The locations tab: Set up the second tab to display a list of saved locations.
- Create a custom table view cell subclass: Create a custom table view cell subclass to handle displaying location information.
- Edit locations: Add functionality to allow editing of items in the locations list.
-
Use NSFetchedResultsController: How do you use
NSFetchedResultsControllerto fetch data from your Core Data store? - Delete Locations: Add the ability to the UI to delete locations, thus removing them from the Core Data store as well.
- Table view sections: Use built-in Core Data functionality to add the ability to display separate sections based on the location category.
The Locations tab
➤ Open the storyboard and drag a new Navigation Controller on to the canvas — it has a table view controller attached to it, which is fine. You’ll use that in a second.
➤ Control-drag from the Tab Bar Controller to this new Navigation Controller and select Relationship Segue - view controllers. This adds the navigation controller to the tab bar.
➤ The Navigation Controller now has a Tab Bar Item that is named “Item”. Rename it to Locations.
➤ Change the navigation bar of the new table view controller so that the title is set to Locations.
The storyboard now looks like this:
➤ Run the app and activate the Locations tab. It doesn’t show anything useful yet:
Design the table view cell
Before you can show any data in the table, you have to design the prototype cell.
➤ Set the prototype cell’s Reuse Identifier to LocationCell.
➤ In the Size inspector, change Row Height to 65.
➤ Drag two Labels on to the cell. Give the top one the text Description and the bottom one the text Address. This is just so you know what they are for.
➤ Set the font of the Description label to System Bold, size 17. Give this label a tag of 100.
➤ Set the font of the Address label to System, size 14. Set the Text color to black with 50% opacity (so its looks like a medium gray). Give it a tag of 101.
The cell will look something like this:
Drag the labels handles so that they are wide enough to span the entire cell, position them vertically to suit your taste, and then set up AutoLayout constraints for the left, top, right, and bottom so that the labels stay in place even if the screen dimensions changed.
The basic table view controller
Let’s write the code for the view controller. You’ve seen table view controllers several times now, so this should be easy.
You’re going to fake the content first, because it’s a good idea to make sure that the prototype cell works before you have to deal with Core Data.
➤ Add a new file to the project and name it LocationsViewController.swift.
Tip: If you want to keep your list of source files neatly sorted by name in the project navigator, then right-click the MyLocations group (the yellow folder icon) and choose Sort by Name from the menu.
➤ Change the contents of LocationsViewController.swift to:
import UIKit
import CoreData
import CoreLocation
class LocationsViewController: UITableViewController {
var managedObjectContext: NSManagedObjectContext!
// MARK: - Table View Delegates
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
return 1
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "LocationCell",
for: indexPath)
let descriptionLabel = cell.viewWithTag(100) as! UILabel
descriptionLabel.text = "If you can see this"
let addressLabel = cell.viewWithTag(101) as! UILabel
addressLabel.text = "Then it works!"
return cell
}
}
You’ve faked a single row with some placeholder text in the labels. You’ve also given this class an NSManagedObjectContext property even though you won’t be using it yet.
➤ Switch to the storyboard, select the Locations scene, and in the Identity inspector, change the Class of the table view controller to LocationsViewController — be careful with the auto completion when you’re doing this since you also have a LocationsDetailViewController and that might get auto added if you are not careful…
➤ Run the app to make sure the table view works.
Great! Now it’s time to fill the table with the Location objects from the data store.
Get Locations from data store
➤ Run the app and tag a handful of locations. If there is no data in the data store, then the app doesn’t have much to show…
This new part of the app doesn’t know anything yet about the Location objects that you have added to the data store. In order to display them in the table view, you need to obtain references to these objects somehow. You can do that by asking the data store. This is called fetching.
➤ First, add a new instance variable to LocationsViewController.swift:
var locations = [Location]()
This array will hold the list of Location objects.
➤ Add a viewDidLoad() implementation:
override func viewDidLoad() {
super.viewDidLoad()
// 1
let fetchRequest = NSFetchRequest<Location>()
// 2
let entity = Location.entity()
fetchRequest.entity = entity
// 3
let sortDescriptor = NSSortDescriptor(
key: "date",
ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
do {
// 4
locations = try managedObjectContext.fetch(fetchRequest)
} catch {
fatalCoreDataError(error)
}
}
This may look daunting but it’s actually quite simple. You’re going to ask the managed object context for a list of all Location objects in the data store, sorted by date.
-
The
NSFetchRequestis the object that describes which objects you’re going to fetch from the data store. To retrieve an object that you previously saved to the data store, you create a fetch request that describes the search parameters of the object — or objects — that you’re looking for. -
Here you tell the fetch request you’re looking for
Locationentities. -
The
NSSortDescriptortells the fetch request to sort on thedateattribute, in ascending order so that theLocationobjects that the user added first will be at the top of the list. You can sort on any attribute here — later on, you’ll sort on theLocation’s category as well.That completes the fetch request. It took a few lines of code, but basically you said: “Get all
Locationobjects from the data store and sort them by date.” -
Now that you have a fetch request, you can tell the context to execute it. The
fetch()method returns an array with the sorted objects, or throws an error in case something went wrong. That’s why this happens inside ado-try-catchblock.If everything goes well, you assign the results of the fetch to the
locationsinstance variable.
Note: To create the fetch request you wrote
NSFetchRequest<Location>.The
< >mean thatNSFetchRequestis a generic. Recall that arrays are also generics — to create an array you specify the type of objects that go into the array, either using the shorthand notation[Location], or the longerArray<Location>.To use an
NSFetchRequest, you need to tell it what type of object you’re going to be fetching. Here, you create anNSFetchRequest<Location>so that the result offetch()is an array ofLocationobjects.
You could have simplified the code above by combining sections 1 and 2 as follows:
let fetchRequest = NSFetchRequest<Location>(entityName: "Location")
This does the same thing as the previous code by specifying the entity name in the NSFetchRequest initializer. However, this code is a bit more error prone since you are relying on a string name for the entity. If you misspelt something — for example, you said “Locations” instead of “Location” — your code would crash when you ran it because Core Data can’t find the entity.
Using the actual Location object to return the underlying entity prevents that kind of mistake. So our code might be a bit more verbose, but it’s also safer.
Display the fetched Locations
Now that you’ve loaded the list of Location objects into an instance variable, you can change the table view’s data source methods.
➤ Change the data source methods to:
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
return locations.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "LocationCell",
for: indexPath)
let location = locations[indexPath.row]
let descriptionLabel = cell.viewWithTag(100) as! UILabel
descriptionLabel.text = location.locationDescription
let addressLabel = cell.viewWithTag(101) as! UILabel
if let placemark = location.placemark {
var text = ""
if let tmp = placemark.subThoroughfare {
text += tmp + " "
}
if let tmp = placemark.thoroughfare {
text += tmp + ", "
}
if let tmp = placemark.locality {
text += tmp
}
addressLabel.text = text
} else {
addressLabel.text = ""
}
return cell
}
This should have no surprises for you. You get the Location object for the row from the array and then use its properties to fill the labels. Because placemark is an optional, you use if let to unwrap it.
➤ Run the app. Now switch to the Locations tab and… crap! It crashes.
The error message should say something like:
Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value
Exercise: What did you forget?
Answer: You added a managedObjectContext property to LocationsViewController, but never gave this property a value. Therefore, there is nothing to fetch Location objects from.
If you already noticed this and were like, “How come we are not passing the value from SceneDelegate?”, good job! You are really getting the hang of this.
➤ Switch to SceneDelegate.swift. In scene(_:WillConnectTo:options:), change the if let tabBarViewControllers block, as follows:
if let tabViewControllers = tabController.viewControllers {
// First tab
var navController = tabViewControllers[0] as! UINavigationController
let controller1 = navController.viewControllers.first
as! CurrentLocationViewController
controller1.managedObjectContext = managedObjectContext
// Second tab
navController = tabViewControllers[1] as! UINavigationController
let controller2 = navController.viewControllers.first
as! LocationsViewController
controller2.managedObjectContext = managedObjectContext
}
There are a couple of minor changes to the existing code — one is to make navController a variable so that it can be re-used for the second tab, and the second is to rename the controller constant to controller1 to separate it from the the second view controller which would be of a different type.
The code for the second tab looks up the LocationsViewController in the storyboard and gives it a reference to the managed object context, similar to what you did for the first tab.
➤ Run the app again and switch to the Locations tab. Core Data properly fetches the objects and displays them:
Note that the list doesn’t update yet if you tag a new location. You have to restart the app to see the new Location object appear. You’ll solve this later on.
Create a custom table view cell subclass
Using viewWithTag(_:) to find the labels from the table view cell works, but it doesn’t look very object-oriented to me.
It would be much nicer if you could make your own UITableViewCell subclass and give it outlets for the labels. Fortunately, you can, and it’s pretty easy!
➤ Add a new file to the project using the Cocoa Touch Class template. Name it LocationCell and make it a subclass of UITableViewCell. Make sure that the class name does not change when you set the subclass — that can be a little annoying.
➤ Add the following outlets to LocationCell.swift, inside the class definition:
@IBOutlet var descriptionLabel: UILabel!
@IBOutlet var addressLabel: UILabel!
➤ Open the storyboard and select the prototype cell that you made earlier. In the Identity inspector, set Class to LocationCell.
➤ Now you can connect the two labels to the two outlets. This time the outlets are not on the view controller but on the cell, so use the LocationCell’s Connections inspector to connect the descriptionLabel and addressLabel outlets.
That is all you need to do to make the table view use your own table view cell class. But, you do need to update LocationsViewController to make use of it.
➤ In LocationsViewController.swift, replace tableView(cellForRowAt) with the following:
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "LocationCell",
for: indexPath) as! LocationCell
let location = locations[indexPath.row]
cell.configure(for: location)
return cell
}
As before, this asks for a cell using dequeueReusableCell(withIdentifier:for:), but now this will be a LocationCell object instead of a regular UITableViewCell. That’s why you’ve added the type cast.
Note that the string LocationCell is the re-use identifier from the placeholder cell, but LocationCell is the class of the actual cell object that you’re getting. They have the same name but one is a String and the other is a UITableViewCell subclass with extra properties. I hope that’s not too confusing.
Once you have the cell reference, you call a new method, configure(for:) to put the Location object into the table view cell.
➤ Add this new method to LocationCell.swift:
// MARK: - Helper Method
func configure(for location: Location) {
if location.locationDescription.isEmpty {
descriptionLabel.text = "(No Description)"
} else {
descriptionLabel.text = location.locationDescription
}
if let placemark = location.placemark {
var text = ""
if let tmp = placemark.subThoroughfare {
text += tmp + " "
}
if let tmp = placemark.thoroughfare {
text += tmp + ", "
}
if let tmp = placemark.locality {
text += tmp
}
addressLabel.text = text
} else {
addressLabel.text = String(
format: "Lat: %.8f, Long: %.8f",
location.latitude,
location.longitude)
}
}
Instead of using viewWithTag(_:) to find the description and address labels, you now simply use the descriptionLabel and addressLabel properties of the cell.
➤ Run the app to make sure everything still works. If you have a location without a description, the table cell will now say “(No Description)”. If there is no placemark, the address label contains the GPS coordinates.
When using a custom subclass for your table view cells there is no limit to how complex the cell functionality can be.
Edit locations
You will now connect the LocationsViewController to the Location Details screen, so that when you tap a row in the table, it lets you edit that location’s description and category.
You’ll be re-using the LocationDetailsViewController but have it edit an existing Location object rather than add a new one.
Create edit segue
➤ Go to the storyboard. Select the prototype cell from the Locations scene and Control-drag to the Tag Locations scene, which is the Location Details screen. Add a Show selection segue and set its Identifier to EditLocation.
At this point the storyboard should look like this:
There are now two segues from two different screens going to the same view controller.
This is the reason why you should build your view controllers to be as independent of their “calling” controllers as possible. You can then easily re-use them somewhere else in your app.
Soon, you will be calling this same screen from yet another place. In total there will be three segues to it.
➤ Go to LocationsViewController.swift and add the following code:
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "EditLocation" {
let controller = segue.destination as! LocationDetailsViewController
controller.managedObjectContext = managedObjectContext
if let indexPath = tableView.indexPath(
for: sender as! UITableViewCell) {
let location = locations[indexPath.row]
controller.locationToEdit = location
}
}
}
This method is invoked when the user taps a row in the Locations screen. It figures out which Location object belongs to the row and puts it in the new locationToEdit property of LocationDetailsViewController. This property doesn’t exist yet, but you’ll add it in a moment.
The Any type
The type of the sender parameter is Any. You have seen this type in a few places before. What is it?
Objective-C has a special type, id, that means “any object”. It’s similar to NSObject except that it doesn’t make any assumptions at all about the underlying type of the object. id doesn’t have any methods, properties or instance variables, it’s a completely naked object reference.
All objects in an Objective-C program can be treated as having type id. As a result, a lot of the APIs from iOS frameworks depend on this special id type. This is a powerful feature of Objective-C, but unfortunately, a dynamic type like id doesn’t really fit in a strongly typed language such as Swift.
Still, we can’t avoid id completely because it’s so prevalent in iOS frameworks. The Swift equivalent of id is the Any type.
The sender parameter from prepare(for:sender:) can be any kind of object, and so has type Any – and thanks to the question mark it can also be nil.
If the segue is triggered from a table view, sender is of type UITableViewCell. If triggered from a button, sender is of type UIButton (or UIBarButtonItem), and so on.
Objects that appear as type Any are not very useful in that form, and you’ll have to tell Swift what sort of object it really is. In the code that you just wrote, indexPath(for:) expects a UITableViewCell object, not an Any object.
You and I both know that sender in this case really is a UITableViewCell because the only way to trigger this segue is to tap a table view cell. With the as! type cast you’re giving Swift your word (scout’s honor!) that it can safely interpret sender as a UITableViewCell.
Of course, if you were to hook up this segue to something else, such as a button, then this assumption is no longer valid and the app will crash.
Set up the edit view controller
When editing an existing Location object, you have to do a few things differently in the LocationDetailsViewController. The title of the screen shouldn’t be “Tag Location” but “Edit Location”. You also must put the values from the existing Location object into the various cells.
The value of the new locationToEdit property determines whether the screen operates in “add” mode or in “edit” mode.
➤ Add these properties to LocationDetailsViewController.swift:
var locationToEdit: Location?
var descriptionText = ""
locationToEdit needs to be an optional because in “add” mode it will be nil.
➤ Update viewDidLoad() to check whether locationToEdit is set:
override func viewDidLoad() {
super.viewDidLoad()
if let location = locationToEdit {
title = "Edit Location"
}
. . .
}
If locationToEdit is not nil, you’re editing an existing Location object. In that case, the title of the screen becomes “Edit Location”.
Note: Xcode gives a warning on the line
if let location = locationToEditbecause you’re not using the value oflocationanywhere. If you click the yellow icon, Xcode suggests that you replace it withif locationToEdit != nil. You will uselocationin a bit, so ignore Xcode’s suggestion.
➤ Also change this line in viewDidLoad():
descriptionTextView.text = descriptionText
You load the value of the new descriptionText variable into the text view.
Now how do you get the values from the locationToEdit object into the text view and labels of this view controller? Swift has a really cool property observer feature that is perfect for this.
➤ Change the declaration of the locationToEdit property to the following:
var locationToEdit: Location? {
didSet {
if let location = locationToEdit {
descriptionText = location.locationDescription
categoryName = location.category
date = location.date
coordinate = CLLocationCoordinate2DMake(
location.latitude,
location.longitude)
placemark = location.placemark
}
}
}
If a variable has a didSet block, then the code in this block is performed whenever you put a new value into that variable — very handy!
Here, you take the opportunity to fill in the view controller’s instance variables with the Location object’s values.
Because prepare(for:sender:) — and therefore locationToEdit’s didSet — is called before viewDidLoad(), this puts the right values on the screen before it becomes visible.
➤ Run the app, go to the Locations tab and tap on a row. The Edit Location screen should now appear with the data from the selected location:
➤ Change the description of the location and press Done.
Nothing happened?! Well, that’s not quite true. Stop the app and run it again. You will see that a new location has been added with the changed description, but the old one is still there as well.
Fix the edit screen
There are two problems to solve:
- When editing an existing location you must save changes to that location instead of creating a new entry.
- The Locations screen doesn’t update to reflect any changes to the data.
The first fix is easy.
➤ Still in LocationDetailsViewController.swift, change the top part of done():
@IBAction func done() {
guard let mainView = . . .
let hudView = HudView.hud(inView: . . .)
let location: Location
if let temp = locationToEdit {
hudView.text = "Updated"
location = temp
} else {
hudView.text = "Tagged"
location = Location(context: managedObjectContext)
}
location.locationDescription = descriptionTextView.text
. . .
The change is straightforward: you only ask Core Data for a new Location object if you don’t already have one. You also make the text in the HUD say “Updated” when the user is editing an existing Location.
Note: I’ve been harping on about the fact that Swift requires all non-optional variables and constants to always have a value. But here you declare
let locationwithout giving it an initial value. What gives?Well, the
ifstatement that follows this declaration always puts a value intolocation, either the unwrapped value oflocationToEdit, or a newLocationobject obtained from Core Data. After theifstatement,locationis guaranteed to have a value. Swift is cool with that.
➤ Run the app again and edit a location. Now the HUD should say “Updated”.
➤ Stop the app and run it again to verify that the object was indeed properly changed. You can also look at the data directly in the SQLite database, of course.
Exercise. Why do you think the table view isn’t being updated after you change a
Locationobject? Recall that the table view also doesn’t update when you tag new locations.
Answer: You fetch the Location objects in viewDidLoad(). But viewDidLoad() is only performed once, when the app starts. After the initial load of the Locations screen, its contents are never refreshed.
In Checklists, you solved this by using a delegate and that would be a valid solution here too. The LocationDetailsViewController could tell you through delegate methods that a location has been added or changed.
However, since you’re using Core Data, there is a better way to do this.
Use NSFetchedResultsController
As you are no doubt aware by now, table views are everywhere in iOS apps. A lot of the time when you’re working with Core Data, you want to fetch objects from the data store and show them in a table view. And when those objects change, you want to do a live update of the table view in response, to show the changes to the user.
So far, you’ve filled the table view by manually fetching the results, but then you also need to manually check for changes and perform the fetch again to update the table. With NSFetchedResultsController, all that manual work is no longer needed.
It works like this: you give NSFetchedResultsController a fetch request, just like the NSFetchRequest you made earlier, and tell it to go fetch the objects. So far nothing new.
But, you don’t put the results from that fetch into your own array. Instead, you read them straight from the fetched results controller. In addition, you make the view controller the delegate for the NSFetchedResultsController. Through this delegate, the view controller is informed that objects have been changed, added or deleted so that it can update the table in response.
➤ In LocationsViewController.swift, replace the locations instance variable with a new fetchedResultsController variable:
lazy var fetchedResultsController: NSFetchedResultsController<Location> = {
let fetchRequest = NSFetchRequest<Location>()
let entity = Location.entity()
fetchRequest.entity = entity
let sortDescriptor = NSSortDescriptor(
key: "date",
ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
fetchRequest.fetchBatchSize = 20
let fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: self.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: "Locations")
fetchedResultsController.delegate = self
return fetchedResultsController
}()
This again uses the lazy initialization pattern with a closure to set everything up. It’s good to get into the habit of lazily loading objects. You don’t allocate them until you first use them. This makes your apps quicker to start and it saves memory.
The code in the closure does the same thing that you used to do in viewDidLoad(): it makes an NSFetchRequest and gives it an entity and a sort descriptor.
Note: Note that the new variable is not just
NSFetchedResultsControllerbutNSFetchedResultsController<Location>, since it’s a generic. You need to tell the fetched results controller what type of objects to fetch.
This is new:
fetchRequest.fetchBatchSize = 20
If you have a huge table with hundreds of objects, then it requires a lot of memory to keep all of these objects around, even though you can only see a handful of them at a time.
The NSFetchedResultsController is pretty smart about this and will only fetch the objects that you can actually see, which cuts down on memory usage. This is all done in the background without you having to worry about it. The fetch batch size setting allows you to tweak how many objects will be fetched at a time.
Once the fetch request is set up, you create the star of the show:
let fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: self.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: "Locations")
The cacheName needs to be a unique name that NSFetchedResultsController uses to cache the search results. It keeps this cache around even after your app quits, so the next time the fetch request is lightning fast, as the NSFetchedResultsController doesn’t have to make a round-trip to the database but can simply read from the cache.
We’ll talk about the sectionNameKeyPath parameter shortly.
The line that sets fetchedResultsController.delegate to self currently gives an error message because LocationsViewController does not conform to the right delegate protocol yet. You’ll fix that in minute.
Now that you have a fetched results controller, you clean up viewDidLoad().
➤ Change viewDidLoad() like this:
override func viewDidLoad() {
super.viewDidLoad()
performFetch()
}
// MARK: - Helper methods
func performFetch() {
do {
try fetchedResultsController.performFetch()
} catch {
fatalCoreDataError(error)
}
}
You still perform the initial fetch in viewDidLoad(), using the new performFetch() helper method. However, if any Location objects change after that initial fetch, the NSFetchedResultsController’s delegate methods are called to let you know about these changes. I’ll show you how in a second.
It’s always a good idea to explicitly set the delegate to nil when you no longer need the NSFetchedResultsController, just so you don’t get any more notifications that were still pending.
➤ For that reason, add a deinit method:
deinit {
fetchedResultsController.delegate = nil
}
The deinit method is invoked when this view controller is destroyed. It may not be strictly necessary to nil out the delegate here, but it’s a bit of defensive programming that won’t hurt.
Note that in this app the LocationsViewController will never actually be deallocated because it’s one of the top-level view controllers in the tab bar. Still, it’s good to get into the habit of writing deinit methods.
Because you removed the locations array, you should also change the table’s data source methods.
➤ Change tableView(_:numberOfRowsInSection:) to:
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
let sectionInfo = fetchedResultsController.sections![section]
return sectionInfo.numberOfObjects
}
The fetched results controller’s sections property returns an array of NSFetchedResultsSectionInfo objects that describe each section of the table view. The number of rows is found in the section info’s numberOfObjects property.
Currently there is only one section in your app, but later you’ll split up the locations by category and then each category will get its own section.
➤ Change tableView(_:cellForRowAt:) to:
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "LocationCell",
for: indexPath) as! LocationCell
let location = fetchedResultsController.object(at: indexPath)
cell.configure(for: location)
return cell
}
Instead of looking into the locations array like you did before, you now ask the fetchedResultsController for the object at the requested index-path. Because it is designed to work closely with table views, NSFetchedResultsController knows how to deal with index-paths, so that’s very convenient.
➤ Make the same change in prepare(for:sender:) to get the correct Location object.
There is still one piece of the puzzle missing. You need to implement the delegate methods for NSFetchedResultsController in LocationsViewController. Let’s use an extension for that, to keep the code organized.
Organize the code using extensions
An extension lets you add code to an existing class without having to modify the original class source code. When you make an extension you say, “here are a bunch of extra methods that also need to go into that class”, and you can do that even if you didn’t write the original class to begin with.
You’ve seen an extension used in Location+CoreDataProperties.swift. That was done to make it easier for Xcode to regenerate this file without overwriting the contents of Location+CoreDataClass.swift.
You can also use extensions to organize your source code. Here you’ll use an extension just for the NSFetchedResultsControllerDelegate methods, so they are not all tangled up with LocationsViewController’s other code. By putting this code in a separate unit, you keep the responsibilities separate.
This makes it easy to spot which part of LocationsViewController plays the role of the delegate. All the fetched results controller delegate stuff happens just in this extension, not in the main body of the class — you could even place this extension in a separate Swift file if you wanted.
➤ Add the following code to the bottom of LocationsViewController.swift, outside of the class implementation:
// MARK: - NSFetchedResultsController Delegate Extension
extension LocationsViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(
_ controller: NSFetchedResultsController<NSFetchRequestResult>
) {
print("*** controllerWillChangeContent")
tableView.beginUpdates()
}
func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?
) {
switch type {
case .insert:
print("*** NSFetchedResultsChangeInsert (object)")
tableView.insertRows(at: [newIndexPath!], with: .fade)
case .delete:
print("*** NSFetchedResultsChangeDelete (object)")
tableView.deleteRows(at: [indexPath!], with: .fade)
case .update:
print("*** NSFetchedResultsChangeUpdate (object)")
if let cell = tableView.cellForRow(
at: indexPath!) as? LocationCell {
let location = controller.object(
at: indexPath!) as! Location
cell.configure(for: location)
}
case .move:
print("*** NSFetchedResultsChangeMove (object)")
tableView.deleteRows(at: [indexPath!], with: .fade)
tableView.insertRows(at: [newIndexPath!], with: .fade)
@unknown default:
print("*** NSFetchedResults unknown type")
}
}
func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange sectionInfo: NSFetchedResultsSectionInfo,
atSectionIndex sectionIndex: Int,
for type: NSFetchedResultsChangeType
) {
switch type {
case .insert:
print("*** NSFetchedResultsChangeInsert (section)")
tableView.insertSections(
IndexSet(integer: sectionIndex), with: .fade)
case .delete:
print("*** NSFetchedResultsChangeDelete (section)")
tableView.deleteSections(
IndexSet(integer: sectionIndex), with: .fade)
case .update:
print("*** NSFetchedResultsChangeUpdate (section)")
case .move:
print("*** NSFetchedResultsChangeMove (section)")
@unknown default:
print("*** NSFetchedResults unknown type")
}
}
func controllerDidChangeContent(
_ controller: NSFetchedResultsController<NSFetchRequestResult>
) {
print("*** controllerDidChangeContent")
tableView.endUpdates()
}
}
Yowza, that’s a lot of code. Don’t let this freak you out! This is the standard way of implementing these delegate methods. For many apps, this exact code will suffice and you can simply copy it over. Look it over for a few minutes to see if this code makes sense to you. You’ve made it this far, so I’m sure it won’t be too hard.
NSFetchedResultsController will invoke these methods to let you know that certain objects were inserted, removed, or just updated. In response, you call the corresponding methods on the UITableView to insert, remove or update rows. That’s all there is to it.
I put print() statements in these methods so you can follow along in the Console as to what is happening. Also note that you’re using the switch statement here. A series of if’s would have worked just as well but switch reads better.
➤ Run the app. Edit an existing location and press the Done button.
The debug area now shows:
*** controllerWillChangeContent
*** NSFetchedResultsChangeUpdate (object)
*** controllerDidChangeContent
NSFetchedResultsController noticed that an existing object was updated and, through updating the table, called your cell.configure(for:) method to redraw the contents of the cell. By the time the Edit Location screen disappears from sight, the table view is updated and your change is visible.
This also works for adding new locations.
➤ Tag a new location and press the Done button.
The debug area says:
*** controllerWillChangeContent
*** NSFetchedResultsChangeInsert (object)
*** controllerDidChangeContent
This time it’s an “insert” notification. The delegate methods tell the table view to do insertRows(at:with:) in response and the new Location object is inserted in the table.
That’s how easy it is. You make a new NSFetchedResultsController object with a fetch request and implement the delegate methods.
The fetched results controller keeps an eye on any changes that you make to the data store and notifies its delegate in response.
It doesn’t matter where in the app you make these changes, they can happen on any screen. When that screen saves the changes to the managed object context, the fetched results controller picks up on it right away.
“It’s not a bug, it’s an undocumented feature”
There is a nasty Core Data bug that has been there for the last few iOS versions but I haven’t been able to reproduce it with iOS 14. Here are the steps to reproduce it in case you run into it:
- Quit the app.
- Run the app again and tag a new location.
- Switch to the Locations tab.
You’d expect the new location to appear in the Locations tab, but it doesn’t.
It’s even possible that the app crashes as soon as you switch tabs — at least, it used to with older versions of iOS, but again, I haven’t seen the crash with iOS 14 yet. The error message is:
CoreData: FATAL ERROR: The persistent cache of section information does not match the current configuration. You have illegally mutated the NSFetchedResultsController's fetch request, its predicate, or its sort descriptor without either disabling caching or using +deleteCacheWithName:
We did no such thing! Interestingly, this problem does not occur when you switch to the Locations tab before you tag the new location.
There are two possible fixes:
- You can delete the cache of the
NSFetchedResultsController. To do this, add the following line toviewDidLoad()before the call toperformFetch():
NSFetchedResultsController<Location>.deleteCache(withName: "Locations")
This is not a great solution because it negates the point of having a cache in the first place.
- You can force the
LocationsViewControllerto load its view immediately when the app starts up. Without this, it delays loading the view until you switch tabs, causing Core Data to get confused. To apply this fix, add the following toapplication(_:didFinishLaunchingWithOptions:), immediately below the line that setscontroller2.managedObjectContext:
let _ = controller2.view
If this problem affects you, then implement one of the above solutions — my suggestion is option #2. Then throw away MyLocations.sqlite and run the app again. Verify that the bug no longer occurs.
Delete locations
Everyone makes mistakes. So, it’s likely that users will want to delete locations from their list at some point. This is a very easy feature to add: you just have to remove the Location object from the data store and the NSFetchedResultsController will make sure it gets dropped from the table — again, through its delegate methods.
➤ Add the following method to LocationsViewController.swift under the table view delegate section:
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath
) {
if editingStyle == .delete {
let location = fetchedResultsController.object(
at: indexPath)
managedObjectContext.delete(location)
do {
try managedObjectContext.save()
} catch {
fatalCoreDataError(error)
}
}
}
You’ve seen tableView(_:commit:forRowAt:) before. It’s part of the table view’s data source protocol. As soon as you implement this method in your view controller, it enables swipe-to-delete.
This method gets the Location object from the selected row and then tells the context to delete that object. This will trigger the NSFetchedResultsController to send a notification to the delegate, which then removes the corresponding row from the table. That’s all you need to do!
➤ Run the app and remove a location using swipe-to-delete. The Location object is dropped from the database and its row disappears from the screen with a brief animation.
Mass editing
Many apps have an Edit button in the navigation bar that triggers a mode that also lets you delete — and sometimes move — rows. This is extremely easy to add.
➤ Add the following line to viewDidLoad() in LocationsViewController.swift:
navigationItem.rightBarButtonItem = editButtonItem
That’s all there is to it. Every view controller has a built-in Edit button that can be accessed through the editButtonItem property. Tapping that button puts the table in editing mode:
➤ Run the app and verify that you can now also delete rows by pressing the Edit button.
Pretty sweet, huh? There’s more cool stuff that NSFetchedResultsController makes really easy, such as splitting up the rows into sections.
Table view sections
The Location objects have a category field. It would be nice to group the locations by category in the table. The table view supports organizing rows into sections and each of these sections can have its own header.
Putting your rows into sections is a lot of work if you’re doing it by hand, but NSFetchedResultsController practically gives you section support for free.
➤ Change the creation of the sort descriptors in the fetchedResultsController initialization block:
lazy var fetchedResultsController: . . . = {
. . .
let sort1 = NSSortDescriptor(key: "category", ascending: true)
let sort2 = NSSortDescriptor(key: "date", ascending: true)
fetchRequest.sortDescriptors = [sort1, sort2]
. . .
Instead of one sort descriptor object, you now have two. First you sort the Location objects by category and inside each of the category groups you sort by date.
➤ Also change the initialization of the NSFetchedResultsController object:
let fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: self.managedObjectContext,
sectionNameKeyPath: "category", // change this
cacheName: "Locations")
The only difference here is that the sectionNameKeyPath parameter is set to “category”, which means the fetched results controller will group the search results based on the value of the category attribute.
You’re not done yet — the table view’s data source also has methods for sections. So far you’ve only used the methods for rows, but now that you’re adding sections to the table, you need to implement a few additional methods.
➤ Add the following methods to the table view delegate section:
override func numberOfSections(
in tableView: UITableView
) -> Int {
return fetchedResultsController.sections!.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
let sectionInfo = fetchedResultsController.sections![section]
return sectionInfo.name
}
Because you let NSFetchedResultsController do all the work already, the implementation of these methods is very simple. You ask the fetcher object for a list of the sections, which is an array of NSFetchedResultsSectionInfo objects, and then look inside that array to find out how many sections there are and what their names are.
Exercise. Why do you need to write
sections!with an exclamation point?
Answer: the sections property is an optional, so it needs to be unwrapped before you can use it. Here you know for sure that sections will never be nil — after all, you just told NSFetchedResultsController to group the search results based on the value of their “category” field — so you can safely force unwrap it using the exclamation mark. Are you starting to get the hang of these optionals already?
➤ Run the app. Play with the categories on the Locations tab and notice how the table view automatically updates. All thanks to NSFetchedResultsController!
You can find the project files for this chapter under 28-Locations-tab in the Source Code folder.