Skip to content

Lesson 3.4: Compositional Layout

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

Learning Objectives

  • How items, groups, and sections of compositional layouts work together
  • How to use supplementary views

Lab Instructions

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.

Step 1

Review Starter Project

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.

Step 2

Create Grid Layout

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.

Step 3

Add Headers and Sections

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!

Step 4

Create Column Layout

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 to padding.
  • Set the section's top and bottom content insets to padding.
  • Assign the return value of generateHeader() to the section's boundarySupplementaryItems 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.

Step 5

Transition Between Layouts

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!