-
Notifications
You must be signed in to change notification settings - Fork 10
Lesson 3.3: Dynamic Data
- Use
UISearchController
- Demonstrate and describe
UICollectionViewDiffableDataSource
-
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 toStoreItemContainerViewController.swift
. The container view controller is responsible for fetching theStoreItem
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 thatUISearchController
is now being used in place ofUISearchBar
. 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 theupdateSearchResults(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. WithinupdateSearchResults(for:)
you'll find two unfamiliar methods:cancelPreviousPerformRequests(withTarget:selector:object:)
andperform(_: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.
-
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
forSectionIdentifierType
andStoreItem
forItemIdentifierType
. To useStoreItem
, you must first make it adopt theHashable
protocol. OpenStoreItem.swift
and update the type definition to includeHashable
:
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 theitems
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 methodconfigureTableViewDataSource(_ tableView: UITableView)
. -
When should the
configureTableViewDataSource(_:)
method be called, and where will theUITableView
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 theprepare(for:sender:)
method to capture the destination view controller of the segue and cast it asStoreItemListTableViewController
. 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 infetchMatchingItems()
—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 whensearchTerm.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 theapply
method on your data source when results are returned in thefetchMatchingItems()
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!
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 namedItemCollectionViewCell
. -
Update the cell's identifier to
Item
and its class toItemCollectionViewCell
. -
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 ofStoreItemCollectionViewController
, 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 aUICollectionViewDiffableDataSource<String, StoreItem>!
instance variable with the namecollectionViewDataSource
. -
Create a new method named
configureCollectionViewDataSource(_:)
that takes in an instance ofUICollectionView
. This method will look very similar toconfigureTableViewDataSource(_:)
, 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
andItemCollectionViewCell
to adoptItemDisplaying
. -
In both
configureTableViewDataSource(_:)
andconfigureCollectionViewDataSource(_:)
, delete the cell configuration code and call your newconfigure(for:storeItemController)
method on cell instead. -
Update
fetchMatchingItems()
to callcollectionViewDataSource.apply(_:animatingDifferences:completion:)
alongside the existing calls on yourtableViewDataSource
. -
Finally, you'll need to call
configureCollectionViewDataSource(_:)
inprepare(for:sender:)
the same way you did for the table view, casting the destination view controller toStoreItemCollectionViewController
. -
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.
Course curriculum resources from:
-
Develop in Swift Data Collections
- Apple Inc. - Education, 2020. Apple Books.