-
Notifications
You must be signed in to change notification settings - Fork 10
Lesson 3.5: Advanced Compositional Layouts
- 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
String
s.
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 theString
values for the search scopes andqueryOptions
to use the newSearchScope
enum. InviewDidLoad()
, use themap
function onSearchScope.allCases
to set thescopeButtonTitles
:
searchController.searchBar.scopeButtonTitles =
SearchScope.allCases.map { $0.title }
- Replace the
queryOptions
constant with the following computed property. This will cause a compilation error in thefetchMatchingItems()
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—useselectedSearchScope.mediaType
in thequery
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.
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
inStoreItemContainerViewController
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 thefetchMatchingItems()
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 thequery
dictionary and callsstoreItemController.fetchItems
. (Remember to replaceselectedSearchScope
with thesearchScope
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 thehandleFetchedItems(_:)
method, you'll append the passed-in items toitemsSnapshot
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.
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 ofitems
passed in increases. For this application, however, it performs just fine. -
You can now use the
createSectionedSnapshot(from:)
method inhandleFetchedItems(_:)
.
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.
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 theconfigureTableViewDataSource(_:)
method inStoreItemContainerViewController
, you'll see the initialization oftableViewDataSource
. Rather than having the cell logic inStoreItemContainerViewController
, this is a good opportunity to move it to your new subclass. You can create a custom initializer that takes bothUITableView
andStoreItemController
arguments (the latter is necessary for configuring the cell). InStoreItemTableViewDiffableDataSource
, 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 theconfigureTableViewDataSource(_:)
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.
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
'sviewDidLoad()
.
collectionView.register(StoreItemCollectionViewSectionHeader.self, forSupplementaryViewOfKind: "Header", withReuseIdentifier: StoreItemCollectionViewSectionHeader.reuseIdentifier)
- In the
configureCollectionViewDataSource(_:)
method ofStoreItemContainerViewController
, create thesupplementaryViewProvider
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.
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 forflowLayout
. -
Open
StoreItemCollectionViewController
and remove theflowLayout
property. This will cause a number of compilation errors. Remove all the flow layout code fromviewDidLoad()
. -
Add a new method to
StoreItemCollectionViewController
that will be used to configure the collection view's layout. You will pass in aSearchScope
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
usingNSCollectionLayoutSize
,NSCollectionLayoutItem
,NSCollectionLayoutGroup
,NSCollectionLayoutSection
, andNSCollectionLayoutBoundarySupplementaryItem
. 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 toStoreItemContainerViewController
:
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
, andgroupWidthDimension
. These can be defined as properties onSearchScope
using a localextension
.
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-insearchScope
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.
Course curriculum resources from:
-
Develop in Swift Data Collections
- Apple Inc. - Education, 2020. Apple Books.