Chapters

Hide chapters

UIKit Apprentice

First Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 13 chapters
Show chapters Hide chapters

28. The Locations Tab
Written by Matthijs Hollemans & Fahim Farook

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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:

The Locations screen
The Locations screen

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 NSFetchedResultsController to 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:

The storyboard after adding the Locations screen
The storyboard after adding the Locations screen

➤ Run the app and activate the Locations tab. It doesn’t show anything useful yet:

The Locations screen in the second tab
The Locations screen in the second tab

Design the table view cell

Before you can show any data in the table, you have to design the prototype cell.

The prototype cell
Yse bgocaqtdi qecj

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.

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
  }
}
The table view with fake data
Wqa kijwe goeg yozl cogo bina

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…

var locations = [Location]()
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)
  }
}
let fetchRequest = NSFetchRequest<Location>(entityName: "Location")

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.

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
}
Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value
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  
}
The list of Locations
Spo noxn et Jolekaixq

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.

@IBOutlet var descriptionLabel: UILabel!
@IBOutlet var addressLabel: UILabel!
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
}
// 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)
  }
}

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.

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.

The Location Details screen is now also connected to the Locations screen
Fgu Nutavuic Puhiiwn xfmeuz ob duc edza zegpirniw ga fqa Casilaigh bpcuot

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

The Any type

The type of the sender parameter is Any. You have seen this type in a few places before. What is it?

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.

var locationToEdit: Location?
var descriptionText = ""    
override func viewDidLoad() {
  super.viewDidLoad()
  if let location = locationToEdit {
    title = "Edit Location"
  }
  . . .
}
descriptionTextView.text = descriptionText
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
    }
  }
}
Editing an existing location
Egaqoll ex ebucgamy bihimeev

Fix the edit screen

There are two problems to solve:

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

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.

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
}()
fetchRequest.fetchBatchSize = 20
let fetchedResultsController = NSFetchedResultsController(
  fetchRequest: fetchRequest,
  managedObjectContext: self.managedObjectContext,
  sectionNameKeyPath: nil, 
  cacheName: "Locations")
override func viewDidLoad() {
  super.viewDidLoad()
  performFetch()
}

// MARK: - Helper methods
func performFetch() {
  do {
    try fetchedResultsController.performFetch()
  } catch {
    fatalCoreDataError(error)
  }
}
deinit {
  fetchedResultsController.delegate = nil
}
override func tableView(
  _ tableView: UITableView, 
  numberOfRowsInSection section: Int
) -> Int {
  let sectionInfo = fetchedResultsController.sections![section] 
  return sectionInfo.numberOfObjects
}
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
}

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.

// 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()
  }
}
*** controllerWillChangeContent
*** NSFetchedResultsChangeUpdate (object)
*** controllerDidChangeContent
*** controllerWillChangeContent
*** NSFetchedResultsChangeInsert (object)
*** controllerDidChangeContent

“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:

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:
NSFetchedResultsController<Location>.deleteCache(withName: "Locations")
let _ = controller2.view

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.

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)
    }
  }
}
Swipe to delete rows from the table
Xgoto se buxaka gizg wqis mfu nobya

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.

navigationItem.rightBarButtonItem = editButtonItem
The table view in edit mode
Yqi bucku voos aw ivas yiri

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.

lazy var fetchedResultsController: . . . = {
  . . .
  let sort1 = NSSortDescriptor(key: "category", ascending: true)
  let sort2 = NSSortDescriptor(key: "date", ascending: true)
  fetchRequest.sortDescriptors = [sort1, sort2]
  . . .
  let fetchedResultsController = NSFetchedResultsController(
    fetchRequest: fetchRequest,
    managedObjectContext: self.managedObjectContext,
    sectionNameKeyPath: "category",              // change this
    cacheName: "Locations")
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
}
The locations are now grouped in sections
Msa yojeboaws efu tip kpuifab oq piwdoett

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now