-
Notifications
You must be signed in to change notification settings - Fork 10
Lesson 3.4: Compositional Layout
- How items, groups, and sections of compositional layouts work together
- How to use supplementary views
In this lab, you will continue to evolve the Emoji Dictionary project by giving it both grid and columnar layouts, a way to switch between them, and simple section headers. You will learn more about all of this in an upcoming lesson. The goal of the lab is to familiarize yourself with the foundational elements of composed collection views.
This lab has a starter project that sets up much of the app for you, as before. You will focus primarily on the layouts and the changes necessary to support section headers. Most Interface Builder elements (such as the prototype cells, the button to change layouts, links to outlets and actions, etc.) have already been set up for you to allow you to focus on the layouts and headers.
Download the starter project and familiarize yourself with the code. Take a few moments to look at some of the key files:
-
Main.storyboard
, so you know how everything is put together. Notice that there are two prototype cells, one for the standard grid layout and the other for the columnar layout. They have different reuse identifiers, but they both use the same class,EmojiCollectionViewCell
. This is possible because all the outlets are the same between both cells; the only thing that changes are their orientations. -
EmojiCollectionViewHeader.swift
, which is an example of setting up a view in code rather than in Interface Builder. This reusable view was set up to appear similar to the section headers on table views. -
EmojiCollectionViewController.swift
, which will house most of the code you're about to write. When you feel comfortable, move on to the next step.
In EmojiCollectionViewController.swift
, beneath the definition for viewWillAppear(_:)
, create a new method called generateGridLayout
that takes no arguments and returns a UICollectionViewLayout
.
func generateGridLayout() -> UICollectionViewLayout {
}
While this could easily have been named generateLayout
, there will be a second layout created in a later step. Planning ahead and using a more specific name allows you to avoid renaming the function later.
Add a CGFloat
constant called padding
and set its value to 20. This will make sure all the padding around items, groups, and sections is consistent and easily modified. Beneath, create a new NSCollectionLayoutItem
and set its height and width dimensions to 100% of the container's. (This should feel familiar.)
let padding: CGFloat = 20
let item = NSCollectionLayoutItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
)
Next, define a horizontal NSCollectionLayoutGroup
and set its width dimension to 100% of its container's width, its height to one-quarter of the container's height, the subitem
to the item
created above, and the count
to 2, as you are creating a grid layout that is two cells across.
“let group = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1/4)
),
subitem: item,
count: 2
)
This group will require some empty space around it and between its items to keep everything readable and to clearly delineate the cells. Add some interItemSpacing
and contentInsets
to the group:
“group.interItemSpacing = .fixed(padding)
group.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: padding,
bottom: 0,
trailing: padding
)
The interItemSpacing
property sets the interior space between cells contained by the group. The contentInsets
sets the standard padding on the leading and trailing edges of the group (or row). While you could use the contentInsets
property of item
to set the inter-item spacing, ensuring that you don't end up with twice the padding
between the items in the row (where the items' leading and trailing edges meet) or the wrong amount of padding at the outer edges is challenging. It's easier to use this combination of interItemSpacing
and group contentInsets
.
Next, create a new NSCollectionLayoutSection
from the group, set its interGroupSpacing
to padding
, and change its top and bottom content insets to padding
. Finally, return a new UICollectionViewCompositionalLayout
based on the newly created section.
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(
top: padding,
leading: 0,
bottom: padding,
trailing: 0
)
return UICollectionViewCompositionalLayout(section: section)
A section's interGroupSpacing
is similar to a group's interItemSpacing
(other than it being a CGFloat
and not an enum). This is essentially the same as what you did above for the group, just rotated 90°.
To actually use the new layout, create a new UICollectionViewLayout
? variable called layout
beneath the emojis
definition at the top of the class. Next, locate viewDidLoad()
and, beneath the call to super
, add the following:
layout = generateGridLayout()
if let layout = layout {
collectionView.collectionViewLayout = layout
}
This code may feel a little verbose at this stage, but it will reduce the number of changes later. Build and run your app to make sure there aren't any errors and to see what you've created already.
The sections will separate the emojis based on the first letter of their names and present them alphabetically. First, create a few constants to use as identifiers for the headers. Place the following code underneath the reuseIdentifier
definition at the top of EmojiCollectionViewController.swift
.
private let headerIdentifier = "Header"
private let headerKind = "header"
Open up Emoji.swift
and, at the bottom, add a definition for a new struct called Section
. It should conform to Codable
and contain two variable properties: a title
that holds a String
and emojis
, which holds an array of Emoji
.
struct Section: Codable {
var title: String
var emojis: [Emoji]
}
Still in Emoji.swift
, add a useful calculated property to Emoji
called sectionTitle
, as follows, beneath the other property definitions:
var sectionTitle: String {
String(name.uppercased().first ?? "?")
}
This property takes the uppercased name of the emoji (to ignore case and to assure section titles are capitalized) and extracts the first Character
from it (remember that a String
can be operated on as an array of Characters
). Since first
returns an optional, a default value is assigned whenever it is nil
by using the nil-coalescing operator. Finally, since the title
is expected to be a String
, the value is cast to a String
before it's returned.
Back in EmojiCollectionViewController.swift
, beneath the definition for emojis
, add a new variable called sections
that's an array of Sections
. Initialize it to an empty array.
var sections: [Section] = []
Next, register the header as a supplementary view for the collection view by adding the following to viewDidLoad()
, beneath the call to super
:
collectionView.register(EmojiCollectionViewHeader.self,
forSupplementaryViewOfKind: headerKind, withReuseIdentifier:
headerIdentifier)
In a collection view, a supplementary view is registered with a normal reuse identifier and a “kind,” which is a string used to indicate its purpose. Supplementary views are not limited to just headers and footers.
Underneath generateGridLayout()
, add the following method:
func updateSections() {
sections.removeAll()
let grouped = Dictionary(grouping: emojis, by: { $0.sectionTitle })
for (title, emojis) in grouped.sorted(by: { $0.0 < $1.0 }) {
sections.append(
Section(
title: title,
emojis: emojis.sorted(by: { $0.name < $1.name })
)
)
}
}
Take some time to walk through this method and understand what's happening here. First, the sections
array is emptied out. This is OK because the emojis
array is the master data source. The sections
array is used to group things for display.
Next, a new dictionary is created using Dictionary
's init(grouping:by:)
constructor. This creates a dictionary with keys equal to the calculated section titles from all the emojis in the data source and values that are arrays of the emojis matching the section title. Here's an example to illustrate:
var emojis: [Emoji] = [
Emoji(symbol: "😕", name: "Confused Face", description:
"A confused, puzzled face.", usage: "unsure what to
think; displeasure"),
Emoji(symbol: "🍝", name: "Spaghetti", description:
"A plate of spaghetti.", usage: "spaghetti"),
Emoji(symbol: "📚", name: "Stack of Books", description:
"Three colored books stacked on each other.",
usage: "homework, studying"),
Emoji(symbol: "💤", name: "Snore", description:
"Three blue \'z\'s.", usage: "tired, sleepiness"),
Emoji(symbol: "🏁", name: "Checkered Flag", description:
"A black-and-white checkered flag.",
usage: "completion")
]
let grouped = Dictionary(grouping: emojis, by: { $0.sectionTitle })
/*
grouped = [
"C": [
Emoji(symbol: "😕", name: "Confused Face",
description: "A confused, puzzled face.",
usage: "unsure what to think; displeasure"),
Emoji(symbol: "🏁", name: "Checkered Flag",
description: "A black-and-white checkered
flag.", usage: "completion")
],
"S": [
Emoji(symbol: "🍝", name: "Spaghetti",
description: "A plate of spaghetti.",
usage: "spaghetti"),
Emoji(symbol: "📚", name: "Stack of Books",
description: "Three colored books stacked on
each other.", usage: "homework, studying"),
Emoji(symbol: "💤", name: "Snore", description:
"Three blue \'z\'s.", usage: "tired,
sleepiness")
]
]
*/
updateSections()
loops over the grouped
dictionary to sort the entries by key (the section titles), and a new Section
is created from each key/value pair. The emojis in the section are sorted by name before being stored. This new Section
is appended to the sections
array, creating a sorted and grouped list of all emojis for display.
Add a call to updateSections()
in viewWillAppear(_:)
, between the call to super
and the call to collectionView.reloadData()
. This assures that the sections are updated before updating the collection view.
In numberOfSections(in:)
, change the return value to the count of sections.
override func numberOfSections(in collectionView:
UICollectionView) -> Int {
return sections.count
}
The method above finds the appropriate section for the emoji using the calculated sectionTitle
and then attempts to find the emoji's index in the emojis
array of the section
that was calculated. If both indexes are found, a new IndexPath
is returned using the two values. If one or neither is found, an IndexPath
cannot be determined and nil
is returned instead.
Next, complete unwindToEmojiTableView(segue:)
with the following code (beneath the guard statement):
if let path = collectionView.indexPathsForSelectedItems?.first,
let i = emojis.firstIndex(where: { $0 == emoji })
{
emojis[i] = emoji
updateSections()
collectionView.reloadItems(at: [path])
} else {
emojis.append(emoji)
updateSections()
if let newIndexPath = indexPath(for: emoji) {
collectionView.insertItems(at: [newIndexPath])
}
}
This checks the selected collection view items to see if one is selected. (There will be only one here, since the collection view was not configured to allow multiple items to be selected.) It then checks the source emojis list for the emoji that was just modified and grabs its index. If both of these have values, the operation is an update and the emoji in the data source is replaced with the updated Emoji value, the sections are updated, and the collection view reloads the item that was just modified.
If either of the two values is nil
, the operation is an add. The new Emoji
is added to the master emoji list, the sections are updated, the path to the emoji in the sections
array is calculated, and the new item is added to the collection view.
Locate deleteEmoji(at:)
and complete the method with the following:
let emoji = sections[indexPath.section].emojis[indexPath.item]
guard let index = emojis.firstIndex(where: { $0 == emoji }) else
{ return }
emojis.remove(at: index)
sections[indexPath.section].emojis.remove(at: indexPath.item)
collectionView.deleteItems(at: [indexPath])
To delete an emoji from the collection view, the method first finds the emoji to be deleted using indexPath
. It then determines the index of the emoji in the master emoji data source. If it cannot be found, the method skips the remaining steps, since there's nothing to delete.
Using the emoji's index and indexPath
, the emoji is removed from both emojis
and sections
. Unlike cases above where emojis
could be modified and updateSections()
called afterward, a delete requires the arrays to be modified individually. Calling updateSections()
here would confuse the collection view layout engine, because the updated sections may not match the expected layout. Finally, the item is deleted from the collection view.
With section management in place, a few more steps are required to set up the headers for the sections. First, add an override for UICollectionViewDataSource.collectionView(_:viewForSupplementaryElementOfKind:at:)
:
override func collectionView(_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String, at indexPath:
IndexPath) -> UICollectionReusableView {
let header =
collectionView.dequeueReusableSupplementaryView(ofKind:
kind, withReuseIdentifier: headerIdentifier, for:
indexPath) as! EmojiCollectionViewHeader
header.titleLabel.text = sections[indexPath.section].title
return header
}
When the collection view asks for a header, this method is called to configure the view. Similar to regular collection view items, a reusable view is dequeued, configured with the section's title, and returned.
Above updateSections()
, add a new method called generateHeader()
. This will create the header's layout item for the compositional layout. This will be used in your current layout and the one you'll create in the next step—but it's OK if you don't always know in advance that you'll be reusing code. It can always be written and later refactored and optimized once a pattern emerges.
func generateHeader() ->
NSCollectionLayoutBoundarySupplementaryItem {
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
elementKind: headerKind,
alignment: .top
)
header.pinToVisibleBounds = true
return header
}
This method creates and returns an NSCollectionLayoutBoundarySupplementaryItem
configured to be the full width of its container and 40 points high. The kind is set to headerKind
(note that this matches the configuration from UICollectionViewDataSource
), and the alignment for the boundary item is set to the top of the container. Setting the pinToVisibleBounds
property to true
tells the collection view to create a “sticky” header that is pinned to the top of the collection view and only scrolls off when its associated container is no longer visible. You may have seen similar behavior in table views.
Finally, add the following to generateGridLayout()
, right before the return statement:
section.boundarySupplementaryItems = [generateHeader()]
This tells the layout that all the sections will have a supplementary item attached to one of their boundaries. Build and run your app and see how you did. Excellent work!
In EmojiCollectionViewController.swift
, above generateGridLayout()
, add a new method called generateColumnLayout()
that also returns UICollectionViewLayout
. Complete it as follows:
- Set a
padding
CGFloat
constant to 10. - Create an item that's 100% of the container's height and width.
- Define a horizontal group that's 100% of its container's width and 120 points high. The group should contain the
item
that was just defined as its only item. - Set the group's leading and trailing content insets to
padding
. - Create a new section from the group that was just created and set its
interGroupSpacing
topadding
. - Set the section's top and bottom content insets to
padding
. - Assign the return value of
generateHeader()
to the section'sboundarySupplementaryItems
array. - Return a new
UICollectionViewCompositionalLayout
.
Try to set up the method yourself before checking your work with the code below. You can do it!
func generateColumnLayout() -> UICollectionViewLayout {
let padding: CGFloat = 10
let item = NSCollectionLayoutItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(120)
),
subitem: item,
count: 1
)
group.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: padding,
bottom: 0,
trailing: padding
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = padding
section.contentInsets = NSDirectionalEdgeInsets(
top: padding,
leading: 0,
bottom: padding,
trailing: 0
)
section.boundarySupplementaryItems = [generateHeader()]
return UICollectionViewCompositionalLayout(section: section)
}
Next, at the top of the file, beneath the definition for reuseIdentifier
, add a new constant columnReuseIdentifier
with the value "ColumnItem"
.
private let columnReuseIdentifier = "ColumnItem”
To see your new layout in action, change layout
in viewDidLoad()
to call your new generateColumnLayout()
method rather than generateGridLayout()
and switch the reuse identifier in the dequeue operation in collectionView(_:cellForItemAt:)
to columnReuseIdentifier
. Build and run your app to make sure everything works. Good job … now, before you forget, restore the values of layout
and the reuse identifier used to dequeue.
Obviously, switching layouts using the method above is a little cumbersome and not accessible by your app's users. There's a button on the left side of the app's navigation bar for the user to trigger the change. You'll now add code to activate that button and take care of all its underlying needs.
Underneath the definition for sections
, add a new enum called Layout
with two cases: grid
and column
.
enum Layout {
case grid
case column
}
Change the definition of layout
to a dictionary keyed by Layout
with a value of UICollectionViewLayout
.
var layout: [Layout: UICollectionViewLayout] = [:]
In viewDidLoad()
, replace the assignment for layout
and update the if-let
statement that follows.
layout[.grid] = generateGridLayout()
layout[.column] = generateColumnLayout()
if let layout = layout[activeLayout] {
collectionView.collectionViewLayout = layout
}
You should see an error, since activeLayout
was never defined. Add that now, between the definitions of Layout
and layout
.
var activeLayout: Layout = .grid {
didSet {
if let layout = layout[activeLayout] {
self.collectionView.reloadItems(at:
self.collectionView.indexPathsForVisibleItems)
collectionView.setCollectionViewLayout(layout,
animated: true) { (_) in
switch self.activeLayout {
case .grid:
self.layoutButton.image = UIImage(systemName:
"rectangle.grid.1x2")
case .column:
self.layoutButton.image = UIImage(systemName:
"square.grid.2x2")
}
}
}
}
}
This property is set to the default Layout
of .grid
and, whenever the value is changed, didSet
will perform a series of operations:
- Locate the new requested layout.
- If the new layout is found, reload the visible items in the collection view.
- Update the collection view's layout to the new layout and animate the changes.
- When the transition to the new layout is complete, update the navigation bar icon to the icon for the alternative layout.
Note that layoutButton
has already been defined as an @IBOutlet
and is linked to the matching element in the storyboard.
It's important to call reloadItems
before the collection view layout is updated, as you have done here, for a smooth transition between layouts.
Now, find the definition for switchLayouts(sender:)
. Note that this is an @IBAction
, and it's already been linked to the navigation bar button in the storyboard. Using a switch
statement on the activeLayout
, set activeLayout
to the alternative layout in each case.
switch activeLayout {
case .grid:
activeLayout = .column
case .column:
activeLayout = .grid
}
In collectionView(_:cellForItemAt:)
, create a new identifier
constant with the reuse identifier that matches the current layout (reuseIdentifier
for .grid
and columnReuseIdentifier
for .column
). Change the dequeueing operation to use the new identifier constant for the reuse identifier. This make sure the collection view uses the right cell for the right layout.
let identifier = activeLayout == .grid ? reuseIdentifier :
columnReuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
identifier, for: indexPath) as! EmojiCollectionViewCell
Your app is now complete. Build and run it and see how you did. Step through the app and make sure all the features work as expected. Good work!
Course curriculum resources from:
-
Develop in Swift Data Collections
- Apple Inc. - Education, 2020. Apple Books.