Skip to content

Lesson 3.3: Dynamic Data

Ben Gohlke edited this page May 13, 2021 · 5 revisions

Learning Objectives

  • Use UISearchController
  • Demonstrate and describe UICollectionViewDiffableDataSource

Lab Instructions

Step 1

Review Provided Refactoring

  • In the refactored solution for iTunes Search, open Main.storyboard.

  • Notice the changes: Rather than a single table view controller, there's a navigation controller whose root view controller has embed segues to the table view controller and to a new collection view controller. The root view controller sitting in the middle is the container view controller.

  • The container view controller has two subviews that contain the table view controller's view and the collection view controller's view. In the Document Outline, you'll see these as “Table Container View” and “Collection Container View.” The container view controller has view properties for each of the other two view controllers, which are set when the embed segues are performed.

  • There's also a segmented control at the bottom, which toggles the isHidden property for both container views. Initially, the table container view is visible and the collection container view is hidden. The user can toggle the segmented control to view the results as a list or a grid.

  • Open StoreItemListTableViewController.swift and notice that the class implementation has mostly been removed. It has been moved to StoreItemContainerViewController.swift. The container view controller is responsible for fetching the StoreItem results and populating both the table and collection view data sources. Because the data will be the same for both the table view and the collection view, it makes sense to maintain it in one view controller. Using view controller containment is a great way to solve this type of problem.

  • In StoreItemContainerViewController, you'll find that UISearchController is now being used in place of UISearchBar. It's configured to always be visible and to display its scope bar—a built-in segmented control you'll use to let the user select the type of media they're searching for.

  • One major difference is that UISearchController will invoke the updateSearchResults(for:) method anytime the search bar content changes or becomes active. Because you don't want to send a request to the iTunes Search Service with every keystroke, you'll “debounce” the user input by only issuing network requests after the user has stopped typing for a brief period. Within updateSearchResults(for:) you'll find two unfamiliar methods: cancelPreviousPerformRequests(withTarget:selector:object:) and perform(_:with:afterDelay:). With every keystroke, you queue up a new request and cancel previous ones—until the user stops typing for 0.3 seconds. Debouncing is a common technique for search controls that interact with a web service.

Now that you're familiar with the major components of the provided refactor, you're ready to begin making changes.

Step 2

Add Diffable Data Source for Table View

  • One thing missing from the refactor is the implementation of the table view's data source methods—which means the app won't run as-is. These were intentionally left out so that you wouldn't need to delete them. For this lab, you'll be using diffable data sources for both the table and collection view.

  • Your diffable data source will use String for SectionIdentifierType and StoreItem for ItemIdentifierType. To use StoreItem, you must first make it adopt the Hashable protocol. Open StoreItem.swift and update the type definition to include Hashable:

struct StoreItem: Codable, Hashable {

Note that the refactor also added trackId and collectionId as properties. These are important to ensure unique values for Hashable's hashing function.

  • In StoreItemContainerViewController, add a new property for the table view's diffable data source.
var tableViewDataSource: UITableViewDiffableDataSource<String, StoreItem>!
  • Add the following method to configure the diffable data source for the table view:
func configureTableViewDataSource(_ tableView: UITableView) {
    tableViewDataSource = UITableViewDiffableDataSource<String, StoreItem>(
        tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
        
        let cell = tableView.dequeueReusableCell(withIdentifier: "Item", for: indexPath) as! ItemTableViewCell
 
        cell.titleLabel.text = item.name
        cell.detailLabel.text = item.artist
        cell.itemImageView?.image = UIImage(systemName: "photo")
 
        self.storeItemController.fetchImage(from: item.artworkURL) { (result) in
            switch result {
            case .success(let image):
                DispatchQueue.main.async {
                    cell.itemImageView.image = image
                }
            case .failure(let error):
                print("Error fetching image: \(error)")
            }
        }
 
        return cell
    })
}
  • To populate the data source, you'll need an instance of NSDiffableDataSourceSnapshot<String, StoreItem>. Add a computed property to create this from the items array. There should be a single section in the snapshot.

  • You've defined the data source property, but it's not initialized. Add a new method that creates the data source with a cellProvider closure. You can reference the cell configuration implementation from your iTunes Search (Part 3) lab solution. Name the method configureTableViewDataSource(_ tableView: UITableView).

  • When should the configureTableViewDataSource(_:) method be called, and where will the UITableView argument come from? If you recall from the storyboard, each container view has an associated segue that's used to embed the root view of its destination view controller. You can use the prepare(for:sender:) method to capture the destination view controller of the segue and cast it as StoreItemListTableViewController. Then call your configure data source method.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if let tableViewController = segue.destination as?
       StoreItemListTableViewController {
        configureTableViewDataSource(tableViewController.tableView)
    }
}
  • Finally, you'll need to apply the snapshot to this data source when the user performs a search. In the previous iTunes Search lab, you called table.reloadData() in two places in fetchMatchingItems()—once before making the fetch request, and again when results were successfully returned. In this lab, you'll apply the snapshot when results have been successfully returned, but you won't provide an empty snapshot before initiating the request. Instead, you'll add an else clause to apply an empty snapshot only when searchTerm.isEmpty is true. You'll get a better animation this way; any existing items will maintain their identity and change position as the results update. Call the apply method on your data source when results are returned in the fetchMatchingItems() method.
tableViewDataSource.apply(itemsSnapshot, animatingDifferences: true, completion: nil)

Note that you'll need to include self inside the completion closure for fetchItems(matching:completion).

  • At this point, your table view is ready to go. Build and run the app to see that you have it working like it did in the previous lab. And now you have a nice smooth animation as the search results change!

Step 3

Implement Collection View

The collection view controller itself and the mechanism for switching between it and the table view are already in place. Your job is to create the cell and use a compositional layout to achieve the following design.

  • Open Main.storyboard and add a cell to the collection view with the following design. Use a stack view to contain the image view and the two labels.

  • Create a new UICollectionViewCell subclass named ItemCollectionViewCell.

  • Update the cell's identifier to Item and its class to ItemCollectionViewCell.

  • Create outlets between your cell in the storyboard and ItemCollectionViewCell for the following:

    • itemImageView: UIImageView
    • titleLabel: UILabel!
    • detailLabel: UILabel!
  • Next, you'll need to set up the compositional layout. The design has three items per row, with a bit of space between each item. In the viewDidLoad() method of StoreItemCollectionViewController, add the following code to set up a basic compositional layout.

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
 
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.5))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
 
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
section.interGroupSpacing = 8
 
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
  • With the layout and cell design done, you'll create the collection view's data source and update it at the appropriate time using your itemsSnapshot, just like you did for the table view.

  • In StoreItemContainerViewController, add a UICollectionViewDiffableDataSource<String, StoreItem>! instance variable with the name collectionViewDataSource.

  • Create a new method named configureCollectionViewDataSource(_:) that takes in an instance of UICollectionView. This method will look very similar to configureTableViewDataSource(_:), except that you'll dequeue a cell from the collection view rather than a table view. Complete your implementation of the method:

func configureCollectionViewDataSource(_ collectionView: UICollectionView) {
    collectionViewDataSource = UICollectionViewDiffableDataSource<String, StoreItem>(
        collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Item", for: indexPath) as! ItemCollectionViewCell
 
        cell.titleLabel.text = item.name
        cell.detailLabel.text = item.artist
        cell.itemImageView?.image = UIImage(systemName: "photo")
 
        self.storeItemController.fetchImage(from: item.artworkURL)
           { (result) in
            switch result {
            case .success(let image):
                DispatchQueue.main.async {
                    cell.itemImageView.image = image
                }
            case .failure(let error):
                print("Error fetching image: \(error)")
            }
        }
 
        return cell
    })
}
  • Did you notice that a lot of code in this method is identical to the code in configureTableViewDataSource(_:)?

Look at the cell configuration code. Instead of repeating it, you can refactor it into a method that can be called by both data source configuration methods, passing in the cell to be configured. But the type of cell is different in the two methods, so how can you make the method handle both types of cells?

If your answer was to use a protocol, you're right! Protocol-oriented programming is a great way to consolidate code. Your new protocol should define the properties shared by ItemTableViewCell and ItemCollectionViewCell. Create a new Swift file named ItemDisplaying.swift with the following definition:

import UIKit
 
protocol ItemDisplaying {
    var itemImageView: UIImageView! { get set }
    var titleLabel: UILabel! { get set }
    var detailLabel: UILabel! { get set }
}
  • Your cell configuration method can be supplied to all types that adopt the protocol through a protocol extension. Below the definition of ItemDisplaying, add the following extension.
extension ItemDisplaying {
    func configure(for item: StoreItem, storeItemController: StoreItemController) {
        titleLabel.text = item.name
        detailLabel.text = item.artist
        itemImageView?.image = UIImage(systemName: "photo")
 
        storeItemController.fetchImage(from: item.artworkURL) { (result) in
            DispatchQueue.main.async {
                switch result {
                case .success(let image):
                    self.itemImageView.image = image
                case .failure(let error):
                    self.itemImageView.image =
                       UIImage(systemName: "photo")
                    print("Error fetching image: \(error)")
                }
            }
        }
    }
}
  • Now you'll need to update the class definitions of ItemTableViewCell and ItemCollectionViewCell to adopt ItemDisplaying.

  • In both configureTableViewDataSource(_:) and configureCollectionViewDataSource(_:), delete the cell configuration code and call your new configure(for:storeItemController) method on cell instead.

  • Update fetchMatchingItems() to call collectionViewDataSource.apply(_:animatingDifferences:completion:) alongside the existing calls on your tableViewDataSource.

  • Finally, you'll need to call configureCollectionViewDataSource(_:) in prepare(for:sender:) the same way you did for the table view, casting the destination view controller to StoreItemCollectionViewController.

  • Build and run the app to see your collection view in action.

Congratulations! You can now display the same set of data in multiple ways. Collection views, diffable data sources, and protocol-oriented programming are all great tools to reach for when building complex and feature-rich apps. Be sure to save the project to your project folder for future reference.