Skip to content

Lesson 3.5: Advanced Compositional Layouts

Ben Gohlke edited this page May 18, 2021 · 6 revisions

Learning Objectives

Lab Instructions

Step 1

Adding All Search Scope

  • Add a new Swift file named “SearchScope.swift” and define the following enum. This will encapsulate the search scopes into a concrete type that can be used moving forward, rather than relying on Strings.​
enum SearchScope: CaseIterable {
    case all, movies, music, apps, books
 
    var title: String {
        switch self {
        case .all: return "All"
        case .movies: return "Movies"
        case .music: return "Music"
        case .apps: return "Apps"
        case .books: return "Books"
        }
    }
 
    var mediaType: String {
        switch self {
        case .all: return "all"
        case .movies: return "movie"
        case .music: return "music"
        case .apps: return "software"
        case .books: return "ebook"
        }
    }
}

The title property will be used when displaying the name of the search scope, and mediaType is used in the query for the API request. The mediaType values are defined in the web service's API documentation.

  • Open StoreItemContainerViewController and start replacing the String values for the search scopes and queryOptions to use the new SearchScope enum. In viewDidLoad(), use the map function on SearchScope.allCases to set the scopeButtonTitles:
searchController.searchBar.scopeButtonTitles =​
 SearchScope.allCases.map { $0.title }
  • Replace the queryOptions constant with the following computed property. This will cause a compilation error in the fetchMatchingItems() method.
var selectedSearchScope: SearchScope {
    let selectedIndex =
       searchController.searchBar.selectedScopeButtonIndex
    let searchScope = SearchScope.allCases[selectedIndex]
 
    return searchScope
}​
  • Fix the compilation error by removing the mediaType declaration. That will cause a new compilation error—use selectedSearchScope.mediaType in the query dictionary to resolve it.

  • Build and run the app, and you'll see the new All scope. Using it in a search produces a list of results with varying types. This is a good start, but notice that the iTunes Search Service has media types beyond the four that you're interested in, such as podcasts. Unfortunately, the API does not allow you to provide a list of media types you're interested in, such as "movies,music,books,apps", so you'll need to make multiple requests.

Step 2

Making Multiple Requests

When a user has the All search scope selected, you will need to make individual requests for movies, music, apps, and books. This can be achieved using a for-in loop and the same fetchItems(matching:completion:) method you're using now. However, each request will come back individually and in an indeterminate order; you'll need a way to collect the results as they come in and then display them. This is a perfect job for diffable snapshots and data sources.

  • Replace the definition of itemsSnapshot in StoreItemContainerViewController with the following:
​
var itemsSnapshot = NSDiffableDataSourceSnapshot<String, StoreItem>()
  • Remove the items variable, which will cause two compilation errors—these can be resolved by simply removing those lines.

  • To collect each set of items from the multiple API requests, you'll need to append them to the snapshot. But first, the snapshot must be cleared out before the requests are initiated. At the top of fetchMatchingItems(), add the following:

​
itemsSnapshot.deleteAllItems()
  • Now you can collect the returned items, append them to the snapshot, and apply the snapshot to the data sources as they come in. Add a new method to handle this. (You will fill in the implementation more in just a minute.)
​
func handleFetchedItems(_ items: [StoreItem]) {
 
    tableViewDataSource.apply(itemsSnapshot,
       animatingDifferences: true, completion: nil)
    collectionViewDataSource.apply(itemsSnapshot,
       animatingDifferences: true, completion: nil)
}
  • With some of the pieces in place, you're almost ready to create the for-in loop that will send off a request for each of the necessary search scopes. When the search scope is All, you'll send four requests; otherwise, you'll send just one for the selected scope. There are many ways you could write this logic, but one is to simply use an array of SearchScopes and iterate over it. In the fetchMatchingItems() method, after you've checked that the search text is not empty, add the following:
​
let searchScopes: [SearchScope]
if selectedSearchScope == .all {
    searchScopes = [.movies, .music, .apps, .books]
} else {
    searchScopes = [selectedSearchScope]
}
  • Next, add a for-in loop that iterates over searchScopes, wrapping the existing section of code that creates the query dictionary and calls storeItemController.fetchItems. (Remember to replace selectedSearchScope with the searchScope loop variable; otherwise, this new loop won't issue different requests.)
​
for searchScope in searchScopes {
    let query = [
        "term": searchTerm,
        "media": searchScope.mediaType,
        "lang": "en_us",
        "limit": "20"
    ]
 
    storeItemController.fetchItems(matching: query) { (result) in
        switch result {
        case .success(let items):
            DispatchQueue.main.async {
                guard searchTerm ==
                   self.searchController.searchBar.text else {
                    return
                }
 
                self.tableViewDataSource.apply(self.itemsSnapshot,
                   animatingDifferences: true, completion: nil)
                self.collectionViewDataSource.apply(self.itemsSnapshot,
                   animatingDifferences: true, completion: nil)
            }
        case .failure(let error):
            print(error)
        }
    }
}
  • Next, replace the apply calls to both data sources with a call to your new method.
​
self.handleFetchedItems(items)
  • The for-in loop now creates a unique API request for each item in searchScopes and, when finished, successfully calls the handler method. In the handleFetchedItems(_:) method, you'll append the passed-in items to itemsSnapshot to collect the items for each response. Add the following to the beginning of the method, before calling the apply methods on the data sources.
​
let currentSnapshotItems = itemsSnapshot.itemIdentifiers
var updatedSnapshot = NSDiffableDataSourceSnapshot<String,
   StoreItem>()
updatedSnapshot.appendSections(["Results"])
updatedSnapshot.appendItems(currentSnapshotItems + items)
itemsSnapshot = updatedSnapshot​
  • Build and run the app and see what happens when you search for something with the All scope. As you scroll through the list of results, you should see groups of each type of media. Run a few more searches; depending on how the network requests finished, the media types could appear in different orders each time. It would be best if the results were always sorted the same way, and it would probably make sense to match the order of the search scope selector—Movies, Music, Apps, Books.

Step 3

Sectioning Search Results

The collection view layout will be designed so that each group of results is its own section that scrolls orthogonally and each item has a fractional width of one-third and an absolute height of 166 points. But before you can display sections in the table and collection views, your snapshot needs to provide them. How can you create sections for each [StoreItem] coming in from the web service? If you look at the StoreItem model, there is a kind property that defines the type of media the item represents. The API uses the following values for each type:

Type String value for kind
Movie "feature-movie"
Music "song" or "album"
App "software"
Book "ebook"

Given an array of StoreItem instances, you need to group them by kind and create an NSDiffableDataSourceSnapshot<String, StoreItem> instance with the sections in the order Movies, Music, Apps, Books.

  • Take a moment and think about how you might design this algorithm. Use the following method signature to help: ​

func createSectionedSnapshot(from items: [StoreItem]) -> NSDiffableDataSourceSnapshot<String, StoreItem> {
 
}
  • Attempt to write the algorithm using the information provided and the Swift features you're familiar with before viewing the following solution.
func createSectionedSnapshot(from items: [StoreItem]) ->
   NSDiffableDataSourceSnapshot<String, StoreItem> {
 
    let movies = items.filter { $0.kind == "feature-movie" }
    let music = items.filter { $0.kind == "song" || $0.kind == "album" }
    let apps = items.filter { $0.kind == "software" }
    let books = items.filter { $0.kind == "ebook" }
 
    let grouped: [(SearchScope, [StoreItem])] = [
        (.movies, movies),
        (.music, music),
        (.apps, apps),
        (.books, books)
    ]
 
    var snapshot = NSDiffableDataSourceSnapshot<String, StoreItem>()
    grouped.forEach { (scope, items) in
        if items.count > 0 {
            snapshot.appendSections([scope.title])
            snapshot.appendItems(items, toSection: scope.title)
        }
    }
 
    return snapshot
}
  • This algorithm filters each media type into an array, then an array of tuples is created to pair the filtered arrays with the corresponding SearchScope values. The array of tuples is sorted in the desired display order. Finally, an empty snapshot is created and the tuples are iterated over, appending each section to the snapshot with its items if the items count is greater than zero—empty sections are not created.​
There are many ways to go about creating this algorithm, and what you came up with is likely different. This one may not be the most efficient approach, as the number of items passed in increases. For this application, however, it performs just fine.

  • You can now use the createSectionedSnapshot(from:) method in handleFetchedItems(_:).

​
func handleFetchedItems(_ items: [StoreItem]) {
    let currentSnapshotItems = itemsSnapshot.itemIdentifiers
    itemsSnapshot = createSectionedSnapshot(from:
       currentSnapshotItems + items)
 
    tableViewDataSource.apply(itemsSnapshot,
       animatingDifferences: true, completion: nil)
    collectionViewDataSource.apply(itemsSnapshot,
       animatingDifferences: true, completion: nil)
}​

  • Build and run again, and you should see consistent ordering of the results—although you still will not see any section headers.

Step 4

Table View Section Headers

Normally, to display headers in a table view you'd implement the UITableViewDataSource method tableView(_:titleForHeaderInSection:), returning a String. Since you're using UITableViewDiffableDataSource, you've handed over the data source responsibilities to that instance. So to provide titles for the sections headers, you'll need to create a subclass of UITableViewDiffableDataSource and override the tableView(_:titleForHeaderInSection:) method.

  • Add a new Swift filed named “StoreItemTableViewDiffableDataSource.swift” with the following body:
​
import UIKit
 
class StoreItemTableViewDiffableDataSource: UITableViewDiffableDataSource<String, StoreItem> {
 
}
  • This is a concrete subclass of UITableViewDiffableDataSource—it is no longer generic. If you look back at the configureTableViewDataSource(_:) method in StoreItemContainerViewController, you'll see the initialization of tableViewDataSource. Rather than having the cell logic in StoreItemContainerViewController, this is a good opportunity to move it to your new subclass. You can create a custom initializer that takes both UITableView and StoreItemController arguments (the latter is necessary for configuring the cell). In StoreItemTableViewDiffableDataSource, add the following initializer:​

init(tableView: UITableView, storeItemController:
   StoreItemController) {
    super.init(tableView: tableView) { (tableView, indexPath,
       item) -> UITableViewCell? in
 
        let cell = tableView.dequeueReusableCell(withIdentifier:
           "Item", for: indexPath) as! ItemTableViewCell
        cell.configure(for: item, storeItemController:
           storeItemController)
 
        return cell
    }
}
  • Also, add the following method to provide titles for the section headers.​

override func tableView(_ tableView: UITableView,
   titleForHeaderInSection section: Int) -> String? {
    return snapshot().sectionIdentifiers[section]
}
  • Finally, back in StoreItemContainerViewController, update the configureTableViewDataSource(_:) to call your new initializer.

  • Build and run the app, perform a search, and notice that your table view now has section headers. Excellent! It took a lot of work to get here, but you learned how to refactor an app to support a new feature in the process.

Step 5

Collection View Section Headers

It's now time to add section header views to the collection view. To do this, you'll need a UICollectionReusableView. Since the focus of this lab is the layout, the code below is provided for you below. Create a new Swift file named “StoreItemCollectionViewSectionHeader.swift” with the following body:

import UIKit
 
class StoreItemCollectionViewSectionHeader: UICollectionReusableView {
    static let reuseIdentifier = "StoreItemCollectionViewSectionHeader"
 
    let titleLabel: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.font = UIFont.boldSystemFont(ofSize: 17)
 
        return label
    }()
 
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
 
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    func setTitle(_ title: String) {
        titleLabel.text = title
    }
 
    private func setupView() {
        backgroundColor = .systemGray5
 
        addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints =
           false
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: topAnchor,
               constant: 2),
            titleLabel.trailingAnchor.constraint(equalTo:
               trailingAnchor, constant: -15),
            titleLabel.bottomAnchor.constraint(equalTo:
               bottomAnchor, constant: -2),
            titleLabel.leadingAnchor.constraint(equalTo:
               leadingAnchor, constant: 15),
        ])
    }
}

This view closely matches the design of table view's section headers: It has a background color and a label with the section's title.

  • In this lesson, you were required to register supplementary views with your collection view before using them. You'll need to do the same thing here. Add the registration call in StoreItemCollectionViewController's viewDidLoad().
collectionView.register(StoreItemCollectionViewSectionHeader.self, forSupplementaryViewOfKind: "Header", withReuseIdentifier: StoreItemCollectionViewSectionHeader.reuseIdentifier)​
  • In the configureCollectionViewDataSource(_:) method of StoreItemContainerViewController, create the supplementaryViewProvider closure that returns the header view. This process is the same as you used in the lesson.​
collectionViewDataSource.supplementaryViewProvider = { collectionView, kind, indexPath -> UICollectionReusableView? in
    let headerView =
       collectionView.dequeueReusableSupplementaryView(ofKind:
       "Header", withReuseIdentifier:
       StoreItemCollectionViewSectionHeader.reuseIdentifier,
       for: indexPath) as! StoreItemCollectionViewSectionHeader
 
    let title =
       self.itemsSnapshot.sectionIdentifiers[indexPath.section]
       headerView.setTitle(title)
 
    return headerView
}

The section header view is now ready to use, but it must be included in the layout.

Step 6

Collection View Layout

Currently, you are using a flow layout to display the results. Next, you'll create a custom layout using a compositional layout. The layout will be designed so that each group of results is its own section that scrolls orthogonally, and each item will have a fractional width of one-third the group's width and an absolute height of 166 points.  

  • Start by detaching the flow layout from the collection view. Open Main.storyboard and select the Store Item Collection View Controller scene. Using the Connections inspector, remove the outlet for flowLayout.

  • Open StoreItemCollectionViewController and remove the flowLayout property. This will cause a number of compilation errors. Remove all the flow layout code from viewDidLoad().

  • Add a new method to StoreItemCollectionViewController that will be used to configure the collection view's layout. You will pass in a SearchScope property to be used later.​

func configureCollectionViewLayout(for searchScope: SearchScope) {
 
}

This method will need to be called at the appropriate time from StoreItemContainerViewController. In the prepare(for:) method, where segue.destination is unwrapped and cast as StoreItemCollectionViewController, add a call to configureCollectionViewLayout(for:), passing in selectedSearchScope.

if let collectionViewController = segue.destination as? StoreItemCollectionViewController {
    collectionViewController.configureCollectionViewLayout(for: selectedSearchScope)
    configureCollectionViewDataSource(collectionViewController.collectionView)
}
  • Using the description and screenshot of the layout, compose a UICollectionViewCompositionalLayout using NSCollectionLayoutSize, NSCollectionLayoutItem, NSCollectionLayoutGroup, NSCollectionLayoutSection, and NSCollectionLayoutBoundarySupplementaryItem. This will likely be an iterative process as you build and run to see how the layout renders. Take your time and make a good effort before viewing the following solution.
func configureCollectionViewLayout(for searchScope: SearchScope) {
    let itemSize =
       NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3),
       heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .init(top: 8, leading: 5, bottom: 8,
       trailing: 5)
 
    let groupSize =
       NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3),
       heightDimension: .absolute(166))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize:
       groupSize, subitem: item, count: 1)
 
    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior =
       .continuousGroupLeadingBoundary
 
    let headerSize =
       NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
       heightDimension: .absolute(28))
    let headerItem =
       NSCollectionLayoutBoundarySupplementaryItem(layoutSize:
       headerSize, elementKind: "Header", alignment: .topLeading)
 
    section.boundarySupplementaryItems = [headerItem]
 
    let layout = UICollectionViewCompositionalLayout(section:
       section)
    collectionView.collectionViewLayout = layout
}
  • Great work! Your new layout works excellently for the All search scope. However, it feels like it's not taking advantage of the screen real estate when using other search scopes. Instead of orthogonally scrolling when there is only one section, it would make sense to lay the items out similarly to flow layout, as before. You could chose to switch to flow layout in those cases or to adapt your compositional layout.

  • Try adapting the compositional layout when searchScope is not .all. To update the layout for new search scopes, you'll first need a reference to the collection view controller. Add the following property to StoreItemContainerViewController:

weak var collectionViewController:
   StoreItemCollectionViewController?​

In prepare(for:), assign the unwrapped StoreItemCollectionViewController to this property.​

if let collectionViewController = segue.destination as?
   StoreItemCollectionViewController {
    self.collectionViewController = collectionViewController
    collectionViewController.configureCollectionViewLayout(for:
       selectedSearchScope)
    configureCollectionViewDataSource(collectionViewController.
       collectionView)
}
  • Finally, call configureCollectionViewLayout(for:)` in the handleFetchedItems(_:)``` method after the snapshot has been applied.​

func handleFetchedItems(_ items: [StoreItem]) {
    let currentSnapshotItems = snapshot.itemIdentifiers
    let updatedSnapshot = createSectionedSnapshot(from: currentSnapshotItems + items)
    snapshot = updatedSnapshot
    tableViewDataSource.apply(snapshot, animatingDifferences:
       true, completion: nil)
    collectionViewDataSource.apply(snapshot, animatingDifferences:
       true, completion: nil)
 
    collectionViewController?.configureCollectionViewLayout(for:
   selectedSearchScope)
}
  • Now you're ready to adjust the layout as the search scope changes.

  • As with all things in software development, there are many ways you could have approached this problem. One way is to determine which attributes are the same and which are different for the two situations. You can then extract the individual attributes into definitions you can apply. The attributes that differ in this case are orthogonalScrollingBehavior, groupItemCount, and groupWidthDimension. These can be defined as properties on SearchScope using a local extension.​

extension SearchScope {
    var orthogonalScrollingBehavior:
       UICollectionLayoutSectionOrthogonalScrollingBehavior {
        switch self {
        case .all:
            return .continuousGroupLeadingBoundary
        default:
            return .none
        }
    }
 
    var groupItemCount: Int {
        switch self {
        case .all:
            return 1
        default:
            return 3
        }
    }
 
    var groupWidthDimension: NSCollectionLayoutDimension {
        switch self {
        case .all:
            return .fractionalWidth(1/3)
        default:
            return .fractionalWidth(1.0)
        }
    }
}
  • Now, the configureCollectionViewLayout(for:) method can be updated to use these properties from the passed-in searchScope argument.
func configureCollectionViewLayout(for searchScope: SearchScope) {
    let itemSize = NSCollectionLayoutSize(widthDimension:
       .fractionalWidth(1/3), heightDimension:
       .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .init(top: 8, leading: 5,
       bottom: 8, trailing: 5)
 
    let groupSize = NSCollectionLayoutSize(widthDimension:
       searchScope.groupWidthDimension, heightDimension:
       .absolute(166))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize:
       groupSize, subitem: item, count: searchScope.groupItemCount)
 
    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior =
       searchScope.orthogonalScrollingBehavior
 
    let headerSize = NSCollectionLayoutSize(widthDimension:
       .fractionalWidth(1.0), heightDimension: .absolute(28))
    let headerItem =
       NSCollectionLayoutBoundarySupplementaryItem(layoutSize:
       headerSize, elementKind: "Header", alignment: .topLeading)
 
    section.boundarySupplementaryItems = [headerItem]
 
    let layout = UICollectionViewCompositionalLayout(section:
       section)
    collectionView.collectionViewLayout = layout
}​


Congratulations! This was a challenging lab, but you've made a large refactor to the iTunes Search project to support a new feature, and you exercised your ability to create compositional collection view layouts.