Coding Auto Layout by Example — Music Album

Posted: January 19, 2022

Music Album

In this example we are going to build a layout for a music album, similar to what can be seen in the Apple Music app. We’ll learn how to create UITableView with a self–sizing header.

Overview

The header contains the album’s cover, name and artist’s information. It also includes play and shuffle buttons. The views are arranged vertically for the compact size class, while for regular size class the header layout combines horizontal and vertical arrangement. The individual cells contain the track’s order and name. Compact size class also contains an ellipsis button to display a pop up menu. Regular size class includes another label to display the length of the track and a plus button for adding a song to the music library.

Create a new iOS project in Xcode or open the starting project for this example, it is available on GitHub.

View model

As always let’s define the models first inside Models.swift. We’ll start with the header model and nest it inside the Album enum:

import UIKit

enum Album {
    enum Header {
        struct ViewModel {
            let coverImage: UIImage?
            let title: String
            let artistName: String
            let genre: String
            let description: String
        }
    }
}

Next is the view model for the album’s track:

enum Album {
    ...

    enum Track {
        struct ViewModel: Hashable {
            let id: UUID
            let order: String
            let title: String
            let length: String
        }
    }
}

And finally the view model for the album itself:

enum Album {
    ...

    struct ViewModel {
        let header: Album.Header.ViewModel
        let tracks: [Album.Track.ViewModel]
    }
}

Within the Models.swift file we’ll also create a sample album to populate our table view:

enum SampleModel {
    static let album: Album.ViewModel = Album.ViewModel(
        header: Album.Header.ViewModel(
            coverImage: UIImage(named: "album"),
            title: "Whatever People Say I Am, That's What I'm Not",
            artistName: "Arctic Monkeys",
            genre: "ALTERNATIVE • 2006",
            description: "Back in 2006, Arctic Monkeys were cast as revolutionaries for uploading music to the internet. There's nothing quite so radical about their debut, which fuses a punk snarl with the sharp tunes and agitated rhythms of New Wave."),
        tracks: [
            Album.Track.ViewModel(id: UUID(), order: "1", title: "The View from the Afternoon", length: "3:38"),
            Album.Track.ViewModel(id: UUID(), order: "2", title: "I Bet You Look Good on the Dancefloor", length: "2:53"),
            Album.Track.ViewModel(id: UUID(), order: "3", title: "Fake Tales of San Francisco", length: "2:57"),
            Album.Track.ViewModel(id: UUID(), order: "4", title: "Dancing Shoes", length: "2:21"),
            Album.Track.ViewModel(id: UUID(), order: "5", title: "You Probably Couldn't See for the Lights but You Were Staring Straight at Me", length: "2:10"),
            Album.Track.ViewModel(id: UUID(), order: "6", title: "Still Take You Home", length: "2:53"),
            Album.Track.ViewModel(id: UUID(), order: "7", title: "Riot Van", length: "2:14"),
            Album.Track.ViewModel(id: UUID(), order: "8", title: "Red Light Indicates Doors Are Secured", length: "2:23"),
            Album.Track.ViewModel(id: UUID(), order: "9", title: "Mardy Bum", length: "2:55"),
            Album.Track.ViewModel(id: UUID(), order: "10", title: "Perhaps Vampires Is a Bit Strong But…", length: "4:28"),
            Album.Track.ViewModel(id: UUID(), order: "11", title: "When the Sun Goes Down", length: "3:20"),
            Album.Track.ViewModel(id: UUID(), order: "12", title: "From the Ritz to the Rubble", length: "3:13"),
            Album.Track.ViewModel(id: UUID(), order: "13", title: "A Certain Romance", length: "5:31")
        ])
}

Default tint color

By default the system uses blue color as a tint for standard iOS controls. To match the look of the Apple Music app a little more closely, let’s change the default tint color to red on the window object in AppDelegate.swift:

window?.tintColor = .systemRed

NSLayoutConstraint extension

As part of the setup, let’s also create an extension for NSLayoutConstraint that we’ll use later when creating optional constraints. In a new file Extensions.swift paste the following code:

import UIKit

extension NSLayoutConstraint {
    
    func withPriority(_ priority: UILayoutPriority) -> NSLayoutConstraint {
        self.priority = priority
        return self
    }
    
}

Creating table view

Next we want to setup the table view in our root view controller. In order to do that we first need to create a subclass for our cell view:

class AlbumTrackTableViewCell: UITableViewCell {

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        self.setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        
    }
    
}

We’ll keep the class empty for now and return to it later on. Now we go back to our view controller file and initialize the table view along with the data source:

class RootViewController: UIViewController {
    
    private let cellId: String = "trackCell"
    private let tableView = UITableView(frame: .zero, style: .plain)
    private lazy var dataSource = UITableViewDiffableDataSource<Int, Album.Track.ViewModel>(tableView: self.tableView) { tableView, indexPath, track in
        let cell = tableView.dequeueReusableCell(withIdentifier: self.cellId, for: indexPath) as! AlbumTrackTableViewCell
        
        // todo: update the cell with the view model
        
        return cell
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.tableView.translatesAutoresizingMaskIntoConstraints = false
        self.tableView.register(AlbumTrackTableViewCell.self, forCellReuseIdentifier: self.cellId)
        self.tableView.dataSource = self.dataSource
        self.tableView.rowHeight = 40 // (1)
        self.view.addSubview(self.tableView)
    }
}
  1. We are using a fixed height for the table row. Ideally we'd want to support dynamic type and self–sizing cells, but that is outside the scope of this post.

Next we’ll add constraints for the table view in the setupView method. Once again we need a property to hold our active constraints to be able to deactivate them as needed when the size class changes:

private var activeConstraints: [NSLayoutConstraint] = []

...

private func setupView() {
    NSLayoutConstraint.deactivate(self.activeConstraints)
    self.activeConstraints.removeAll()
    
    if self.traitCollection.horizontalSizeClass == .regular {
        if UIDevice.current.orientation.isLandscape {
            // (1)
            self.activeConstraints.append(contentsOf: [
                self.tableView.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 2 / 3),
                self.tableView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
                self.tableView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor),
                self.tableView.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor)
            ])
        } else {
            // (2)
            self.activeConstraints.append(contentsOf: [
                self.tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
                self.tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
                self.tableView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor),
                self.tableView.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor)
            ])
        }
    } else {
        // (3)
        self.activeConstraints.append(contentsOf: [
            self.tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.tableView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor),
            self.tableView.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor)
        ])
    }
    
    NSLayoutConstraint.activate(self.activeConstraints)
}

The constraints are exactly the same as in the Twitter Timeline example. We check the horizontal size class and also the orientation when running in the regular environment to provide slightly different layout for landscape and portrait:

  1. In landscape orientation we limit the width of the table to 23 of the view controller and center it horizontally. Top and bottom anchors are constrainted to the layout margins guide.
  2. In portrait the table view width is not limited and spans from the leading to the trailing edge of the view controller's.
  3. For compact size class, we simply pin the leading and trailing anchors to the view controller's view, while the top and bottom anchors are pinned to the layout margins guide to prevent any safe area conflicts.

Now we have to call this method to get our constraints activated. Since our layout is dependent not only on the size class but also the current orientation, we won’t use the traitCollectionDidChange method this time, because the size class may not change when rotating an iPad and our method would not get called. Instead we’ll rely on viewWillTransition which will be called whenever the size of the view controller’s view is about to change:

 override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    
    self.setupView()
}

Additionally we’ll also call this method in viewDidLayoutSubviews to activate the constraints when the view controller is initialized for the first time. We need to wait for the first layout pass to ensure the device orientation is reporting the correct value (for example we cannot rely on it to be correct in the viewDidLoad method):

private var isInitialized: Bool = false

...

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    if !self.isInitialized {
        self.isInitialized = true
        
        self.setupView()
    }
}

Finally we’ll create a snapshot of our view model and apply it to our data source to populate the table view. We’ll do this during the first layout pass as well as I’ve found there are some layout issues with the header when applying the snapshot within the loadView method:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    if !self.isInitialized {
        ...

        var snapshot = NSDiffableDataSourceSnapshot<Int, Album.Track.ViewModel>()
        snapshot.appendSections([0])
        snapshot.appendItems(self.viewModel.tracks, toSection: 0)
        self.dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
    }
}

Album track cell

We’ll create the cell layout before dealing with the header view. Jump to AlbumTrackTableViewCell.swift and add the following views as member properties:

class AlbumTrackTableViewCell: UITableViewCell {
    
    private let contentStack = UIStackView()
    private let orderLabel = UILabel()
    private let titleLabel = UILabel()
    private let button = UIButton(type: .system)
    private var lengthLabel: UILabel?

    ...
}

Here’s the breakdown of the views:

Music Album

The length label is optional since it is not needed for compact size class.

Content stack view

We begin by adding the content stack view to the cell’s content view in the setupView method:

private func setupView() {
    self.contentStack.axis = .horizontal
    self.contentStack.alignment = .center
    self.contentStack.translatesAutoresizingMaskIntoConstraints = false
    self.contentView.addSubview(self.contentStack)
    
    NSLayoutConstraint.activate([
        self.contentStack.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor), // (1)
        self.contentStack.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor), // (1)
        self.contentStack.topAnchor.constraint(equalTo: self.contentView.topAnchor), // (2)
        self.contentStack.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor) // (2)
    ])
}
  1. Leading and trailing anchors are pinned to the layout margins guide to have some whitespace on the sides, especially for the compact environment.
  2. Top and bottom anchors are pinned to the respective anchors of the content view, which is set to a fixed size of 40 points. The stack view will align the views to the vertical center.

Order label

The first view in the stack is the order label that displays the track’s number within the album:

private func setupView() {
    ...

    self.orderLabel.translatesAutoresizingMaskIntoConstraints = false
    self.orderLabel.textColor = .secondaryLabel
    self.orderLabel.font = UIFont.preferredFont(forTextStyle: .body)
    self.orderLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
    self.contentStack.addArrangedSubview(self.orderLabel)
}

Nothing special here, what’s worth mentioning is the adjusted content hugging priority. We don’t want to stretch the label to fill the cell’s width, therefore we set the priority to high.

Title label

Next is the title label:

private func setupView() {
    ...

    self.titleLabel.translatesAutoresizingMaskIntoConstraints = false
    self.titleLabel.font = UIFont.preferredFont(forTextStyle: .body)
    self.titleLabel.textColor = .label
    self.titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
    self.titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStack.addArrangedSubview(self.titleLabel)
}

In this case we lower the content hugging priority, since we definitely want this label to fill as much space as is available horizontally. Also this label should be the first candidate for compression if all the views in the stack won’t be able to fit at their intrinsic size, thus we set the low compression resistance priority.

Button

The button is available in both regular and compact environments, however its style differs in both. Therefore we only set the content hugging priority now and deal with other properties in a separate method in just a second:

private func setupView() {
    ...

    self.button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
    self.contentStack.addArrangedSubview(self.button)
}

Adapting to size class

The cell contents differ a fair bit for each size class. We’ll create a new method to apply these changes so that we can easily call it whenever we want:

private func updateView() {
    let isRegular: Bool = self.traitCollection.horizontalSizeClass == .regular

    // (1)
    self.contentStack.spacing = isRegular ? 16 : 8

    // (2)
    if isRegular {
        // (3)
        self.button.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(scale: .small)), for: .normal)
        self.button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
        
        // (4)
        self.lengthLabel = UILabel()
        self.lengthLabel?.font = UIFont.preferredFont(forTextStyle: .body)
        self.lengthLabel?.textColor = .secondaryLabel
        self.lengthLabel?.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        self.contentStack.addArrangedSubview(self.lengthLabel!)
    } else {
        // (5)
        self.lengthLabel?.removeFromSuperview()
        self.lengthLabel = nil
        
        // (6)
        self.button.setImage(UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(scale: .small)), for: .normal)
        self.button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0)
        self.button.tintColor = .label
    }
}
  1. We set different content spacing depending on the size class.
  2. We check if we are in the regular or compact environment to determine what changes we need to make.
  3. For regular width, the button contains the plus icon. The button's color is not set here yet, we'll see why in a moment.
  4. We also add the length label to display the track's duration and add it as the last view of the content stack.
  5. For compact size class the length label should be removed in case it exists.
  6. And finally the button changes its icon to ellipsis and the color to label.

Now we can call this method at the end of the setupView method to have the cell properly setup during initialization:

private func setupView() {
    ...

    self.updateView()
}

We also must not forget to update the view when the size class changes, thus we override traitCollectionDidChange:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    
    if previousTraitCollection?.horizontalSizeClass != self.traitCollection.horizontalSizeClass {
        self.updateView()
    }
}

One thing we haven’t set yet is the color of the button for the regular size class. In the screenshot we can see it has the default tint color:

Music Album

The problem is that during the execution of the setupView method the cell’s tint color is still the default system blue, not the window’s red that we set in the app delegate, because the cell is not yet part of the view hierarchy. For this reason we need to apply the color to the button only once we’re sure the view is fully initialized, such as in the layoutSubviews method:

override func layoutSubviews() {
    super.layoutSubviews()
    
    let isRegular: Bool = self.traitCollection.horizontalSizeClass == .regular
    
    if isRegular {
        self.button.tintColor = self.tintColor
    }
}

Update cell with view model

Now it’s time to create an update method to have the cell’s views populated with the track’s view model:

func update(viewModel: Album.Track.ViewModel) {
    self.titleLabel.text = viewModel.title
    self.orderLabel.text = viewModel.order
    self.lengthLabel?.text = viewModel.length
}

Finally we need to make sure we actually call the cell’s update method when we prepare the cell for display. Let’s go back to our view controller file and add the update call to the diffable data source:

private lazy var dataSource = UITableViewDiffableDataSource<Int, Album.Track.ViewModel>(tableView: self.tableView) { tableView, indexPath, track in
    let cell = tableView.dequeueReusableCell(withIdentifier: self.cellId, for: indexPath) as! AlbumTrackTableViewCell
    
    cell.update(viewModel: track)
    
    return cell
}

We can now build and run the project to see the table view populated with the album tracks.

Fixing views alignment

If we look at the cells content carefully, especially on an iPad, we’ll notice that the track titles and plus buttons are not always aligned in between different cells.

Music Album

The first problem is that the width of the order label is different from cell to cell and it is especially noticeable when the number gets to double digits. The second problem is on the opposite side of the stack, with the length label. This label also has a different width in each cell so the position of the button is affected. What can we do to fix it? Set a minimum width for each label!

To set a minimum width for these two labels we’ll simply assume what kind of content they are displaying. The order label shows (likely at most) two digits and the length label shows the time of the track that is usually a few minutes long. With these assumptions we can estimate the minimum width each label needs and add a constraint for that.

The width can be calculated by taking a sample String and using the size(withAttributes:) method to get the bounding box the text will occupy with the given attributes (e.g. font). We’ll create a helpful extension on UILabel in the cell file to return the size of a given text with the label’s font:

fileprivate extension UILabel {
    
    func getMinWidth(forText text: String) -> CGFloat {
        let size: CGSize = text.size(withAttributes: [NSAttributedString.Key.font: self.font as Any])
        return size.width
    }
    
}

Now we can set the minimum width inside the updateView method. Since we’ll be using constraints we need to keep track of them in order to be able to deactivate them when the view is updated:

private var activeConstraints: [NSLayoutConstraint] = []

First we deactivate any previous constraints and then set the width for the order label using an inequality constraint. In case the label wants to be wider than this minimum width we allow it:

private func updateView() {
    NSLayoutConstraint.deactivate(self.activeConstraints)
    self.activeConstraints.removeAll()

    ...

    let minWidth: CGFloat = self.orderLabel.getMinWidth(forText: "00")
    
    self.activeConstraints.append(
        self.orderLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth)
    )
}

We do the same for the length label inside the if branch:

private func updateView() {
    ...

    if isRegular {
        // Length label is initialized here

        let minWidth: CGFloat = self.lengthLabel!.getMinWidth(forText: "00:00")
        
        self.activeConstraints.append(
            self.lengthLabel!.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth)
        )
    }
}

At the end of the method we activate the constraints:

private func updateView() {
    ...

    NSLayoutConstraint.activate(self.activeConstraints)
}

We can run the project and see that are titles and buttons are aligned nicely! This concludes the cell layout and we can move on to the final part — the header.

If we supported dynamic type (changing font size per user's device settings) we'd have to handle this change and update the minimum width. We could also cache the text sizes to avoid calculating it for each cell, though I chose to omit both for simplicity.

Header view

There are two header views we can add to a table view — table header view and section headers. We are going to utilize the former which is used to set a (single) header view for the entire table, as such it should not be confused with the section headers. The table header view is part of the scrolling content and creates a seamless transition to the table rows.

Table view respects only the height of the header’s frame rectangle; it adjusts the width of the header automatically to match the table view’s width. That means it is our responsibility to set the height. Since our header will be using constraints, we don’t know the height in advance. Instead we’ll have to measure the header’s height manually after we define the constraints. We’ll use the systemLayoutSizeFitting method to do this later on. For now we’ll focus on the header’s layout.

Let’s start by creating a new file AlbumHeaderView.swift with a subclass of UIView:

class AlbumHeaderView: UIView {

    init() {
        super.init(frame: .zero)
        
        self.setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupView() {

    }
}

We’ll add the necessary views as member properties:

class AlbumHeaderView: UIView {

    private let contentStackView = UIStackView()
    private let coverView = UIImageView()
    private let labelsStack = UIStackView()
    private let titleLabel = UILabel()
    private let artistLabel = UILabel()
    private let genreLabel = UILabel()
    private let descriptionLabel = UILabel()
    private let playButton = AlbumButton(type: .system)
    private let shuffleButton = AlbumButton(type: .system)
    private let buttonsStack = UIStackView()

    ...

}

Here’s the breakdown of the views:

Music Album

Since the layout differs significantly between the size classes, we’ll split the view setup into separate methods:

private func setupView() {
    if self.traitCollection.horizontalSizeClass == .regular {
        self.setupRegularView()
    } else {
        self.setupCompactView()
    }
}

private func setupRegularView() {

}

private func setupCompactView() {

}

Compact layout

Let’s tackle the compact layout first. We are going to initialize all the views before we add any constraints.

Content stack

We start off with the content stack:

private func setupCompactView() {
    self.contentStackView.axis = .vertical
    self.contentStackView.alignment = .center
    self.contentStackView.distribution = .fill
    self.contentStackView.spacing = 16
    self.contentStackView.translatesAutoresizingMaskIntoConstraints = false
    self.contentStackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 16, trailing: 16)
    self.contentStackView.isLayoutMarginsRelativeArrangement = true
    self.addSubview(self.contentStackView)
}

Note even though the distribution property has a default value of fill we still set it here because we may switch from regular to compact size class and the distribution could change. We are also setting custom layout margins to have a small gap on the sides and the bottom (between the header and table rows).

Cover view

For the cover view we only set some common properties and most importantly decrease the compression resistance. This is necessary in order for Auto Layout to measure the size of the header correctly for the bounds we provide later on:

private func setupCompactView() {
    ...

    self.coverView.translatesAutoresizingMaskIntoConstraints = false
    self.coverView.contentMode = .scaleToFill
    self.coverView.layer.cornerRadius = 8
    self.coverView.layer.masksToBounds = true
    self.coverView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.coverView)
}

Labels stack

Next up are the labels for the name of the album, artist and genre. We set the font and other properties for each and add a nested vertical stack view to simplify the spacing between the labels. Once again the compression resistance must be lowered to receive correct measurements later:

private func setupCompactView() {
    ...

    // Labels stack
    self.labelsStack.axis = .vertical
    self.labelsStack.spacing = 4
    self.labelsStack.translatesAutoresizingMaskIntoConstraints = false
    self.labelsStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.labelsStack)
    
    // Title label
    let font: UIFont = UIFont.preferredFont(forTextStyle: .title2)
    self.titleLabel.font = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold)!, size: font.pointSize)
    self.titleLabel.textAlignment = .center
    self.titleLabel.numberOfLines = 2
    self.titleLabel.textColor = .label
    self.titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.titleLabel)
    
    // Artist label
    self.artistLabel.textAlignment = .center
    self.artistLabel.font = UIFont.preferredFont(forTextStyle: .title2)
    self.artistLabel.numberOfLines = 2
    self.artistLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.artistLabel)
    
    // Genre label
    self.genreLabel.textAlignment = .center
    self.genreLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
    self.genreLabel.textColor = .secondaryLabel
    self.genreLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.genreLabel)
}

Buttons stack

The buttons stack uses fillEqually distribution to have the two buttons sized equally. We create two helper methods addButtons and getButtonImage to initialize the buttons and add them to the stack. We’ll reuse these methods for the regular size class:

private func setupCompactView() {
    ...

    self.buttonsStack.axis = .horizontal
    self.buttonsStack.distribution = .fillEqually
    self.buttonsStack.spacing = 20
    self.buttonsStack.translatesAutoresizingMaskIntoConstraints = false
    self.buttonsStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.buttonsStack)
    
    self.addButtons(to: self.buttonsStack)
}

private func addButtons(to stack: UIStackView) {
    self.playButton.setImage(self.getButtonImage(name: "play.fill"), for: .normal)
    self.playButton.setTitle("Play", for: .normal)
    stack.addArrangedSubview(self.playButton)
    
    self.shuffleButton.setImage(self.getButtonImage(name: "shuffle"), for: .normal)
    self.shuffleButton.setTitle("Shuffle", for: .normal)
    stack.addArrangedSubview(self.shuffleButton)
}

private func getButtonImage(name: String) -> UIImage? {
    let size: CGFloat = UIFont.preferredFont(forTextStyle: .caption1).pointSize
    let config = UIImage.SymbolConfiguration(pointSize: size, weight: .semibold)
    return UIImage(systemName: name, withConfiguration: config)
}

Description label

The final view in the content stack is the description label, nothing special here:

private func setupCompactView() {
    ...
  
    self.descriptionLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
    self.descriptionLabel.textColor = .secondaryLabel
    self.descriptionLabel.numberOfLines = 2
    self.descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.descriptionLabel)
}

Compact size class constraints

With all the views initialized, we can move on to constraints. We’ll create another method and call it at the end of setupCompactView:

private func setupCompactView() {
    ...
  
    self.addCompactConstraints()
}

private func addCompactConstraints() {

}

Once again we’ll add a member property to AlbumHeaderView to keep track of the active constraints:

private var activeConstraints: [NSLayoutConstraint] = []

As always the first thing we should do is deactivate any active constraints before creating new ones:

private func addCompactConstraints() {
    NSLayoutConstraint.deactivate(self.activeConstraints)
    self.activeConstraints.removeAll()
}

We begin by constraining the content stack to all the edges of the header view. We must use optional constraints because the header may only receive its size after its subviews are laid out. That means Auto Layout would not be able to satisfy the constraints below for a moment and could break the layout unexpectedly:

private func addCompactConstraints() {
    ...

    self.activeConstraints.append(contentsOf: [
        self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor).withPriority(.defaultHigh),
        self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor).withPriority(.defaultHigh),
        self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor).withPriority(.defaultHigh),
        self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor).withPriority(.defaultHigh)
    ])
}
The content stack view is using center alignment which means it won't resize the arranged subviews and we should explicitly set the size (width) constraint for every view to override its intrinsic width.

The cover view will be limited to 23 of the content’s width. The height of the image will match its width, creating a square image:

private func addCompactConstraints() {
    ...
  
    self.activeConstraints.append(contentsOf: [
        self.coverView.widthAnchor.constraint(equalTo: self.contentStackView.widthAnchor, multiplier: 2.0 / 3.0),
        self.coverView.heightAnchor.constraint(equalTo: self.coverView.widthAnchor)
    ])
}

For the rest of the views we only set the width anchor to match the layout margin of the content stack:

private func addCompactConstraints() {
    ...
  
    // Labels stack
    self.activeConstraints.append(contentsOf: [
        self.labelsStack.widthAnchor.constraint(equalTo: self.contentStackView.layoutMarginsGuide.widthAnchor)
    ])
    
    // Buttons stack
    self.activeConstraints.append(contentsOf: [
        self.buttonsStack.widthAnchor.constraint(equalTo: self.contentStackView.layoutMarginsGuide.widthAnchor)
    ])
    
    // Description label
    self.activeConstraints.append(contentsOf: [
        self.descriptionLabel.widthAnchor.constraint(equalTo: self.contentStackView.layoutMarginsGuide.widthAnchor)
    ])
}

Finally we activate the constraints:

private func addCompactConstraints() {
    ...
  
    NSLayoutConstraint.activate(self.activeConstraints)
}

This concludes the setup for the compact layout.

Regular layout

We’ll build the regular layout in a very similar fashion — initialize the views first and then add any necessary constraints. Here’s the breakdown of the layout:

Music Album

Content stack

We begin with the content stack. It uses horizontal arrangement with bottom alignment and equal size distribution of the views, resulting in a two column layout with the cover image on the left and labels stack on the right:

private func setupRegularView() {
    self.contentStackView.axis = .horizontal
    self.contentStackView.alignment = .bottom
    self.contentStackView.distribution = .fillEqually
    self.contentStackView.spacing = 30
    self.contentStackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 20, trailing: 20)
    self.contentStackView.isLayoutMarginsRelativeArrangement = true
    self.contentStackView.translatesAutoresizingMaskIntoConstraints = false
    self.addSubview(self.contentStackView)
}

Cover view

The cover view is initialized exactly the same as for the compact layout and added to the content stack:

private func setupRegularView() {
    ...

    self.coverView.translatesAutoresizingMaskIntoConstraints = false
    self.coverView.contentMode = .scaleToFill
    self.coverView.layer.cornerRadius = 4
    self.coverView.layer.masksToBounds = true
    self.coverView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.coverView)
}

Labels stack & labels

In the regular layout the labels stack contains not only all the labels but also the buttons stack. The labels come first and are initialized very similarly to the compact version, we just set custom spacing after the genre and description labels. The labels stack is added to the content stack, while the individual labels are added to the labels stack:

private func setupRegularView() {
    ...
  
    // Labels stack (for the buttons as well)
    self.labelsStack.axis = .vertical
    self.labelsStack.spacing = 8
    self.labelsStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.contentStackView.addArrangedSubview(self.labelsStack)
    
    // Title label
    let font: UIFont = UIFont.preferredFont(forTextStyle: .title1)
    self.titleLabel.font = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold)!, size: font.pointSize)
    self.titleLabel.numberOfLines = 2
    self.titleLabel.textColor = .label
    self.titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.titleLabel)
    
    // Artist label
    self.artistLabel.font = UIFont.preferredFont(forTextStyle: .title1)
    self.artistLabel.numberOfLines = 2
    self.titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.artistLabel)
    
    // Genre label
    self.genreLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
    self.genreLabel.textColor = .secondaryLabel
    self.genreLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.genreLabel)
    self.labelsStack.setCustomSpacing(30, after: self.genreLabel)
    
    // Description label
    self.descriptionLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
    self.descriptionLabel.textColor = .secondaryLabel
    self.descriptionLabel.numberOfLines = 3
    self.descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.descriptionLabel)
    self.labelsStack.setCustomSpacing(30, after: self.descriptionLabel)
}

Buttons stack

The buttons stack is the last view in the right column. Once again we use equal sizing and reuse the addButtons method for the individual button setup:

private func setupRegularView() {
    ...

    self.buttonsStack.axis = .horizontal
    self.buttonsStack.distribution = .fillEqually
    self.buttonsStack.spacing = 16
    self.buttonsStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.labelsStack.addArrangedSubview(self.buttonsStack)
    
    self.addButtons(to: self.buttonsStack)
}

Regular size class constraints

Now we can move on to constraints for the regular layout. It will be much simpler in this case since most of the views are sized automatically by stack views.

We’ll create another method and call it at the end of setupRegularView:

private func setupRegularView() {
    ...
  
    self.addRegularConstraints()
}

private func addRegularConstraints() {

}

As always we deactivate any constraints first:

private func addRegularConstraints() {
    NSLayoutConstraint.deactivate(self.activeConstraints)
    self.activeConstraints.removeAll()
}

We need to constrain the content stack to all the header’s edegs. Again we use optional constraints to prevent any Auto Layout issues before the header view receives its size:

private func addRegularConstraints() {
    self.activeConstraints.append(contentsOf: [
        self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor).withPriority(.defaultHigh),
        self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor).withPriority(.defaultHigh),
        self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor).withPriority(.defaultHigh),
        self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor).withPriority(.defaultHigh)
    ])
}

And the last constraint we want to set is for the cover view. We want to have a square image so we set the height anchor to match its own width anchor. Remember the width of the cover view is set by the content stack due to equal sizing distribution:

private func addRegularConstraints() {
    ...

    self.activeConstraints.append(contentsOf: [
        self.coverView.heightAnchor.constraint(equalTo: self.coverView.widthAnchor)
    ])
}

All that’s left to do is activate the constraints:

private func addRegularConstraints() {
    ...

    NSLayoutConstraint.activate(self.activeConstraints)
}

Artist label color

Maybe you’ve noticed we have not set the color of the artist label anywhere. This is because we want the label to have the color of the application tint. Similar to what we saw during the table cell setup, we can’t reliably set the color during the initialization since the tintColor property of the header may still refer to the default system blue. Due to this we set the color in the layoutSubviews method:

override func layoutSubviews() {
    super.layoutSubviews()
    
    self.artistLabel.textColor = self.tintColor
}

Adapting header to size class

With the setup and constraints done for both size classes, we need to react to the potential change of the size class. As usual we do that in the traitCollectionDidChange method:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    
    if previousTraitCollection?.horizontalSizeClass != self.traitCollection.horizontalSizeClass {
        // (1)
        for view in self.contentStackView.subviews {
            view.removeFromSuperview()
        }
        
        // (2)
        self.contentStackView.removeFromSuperview()
        self.activeConstraints.removeAll()
        
        // (3)
        if self.traitCollection.horizontalSizeClass == .regular {
            self.setupRegularView()
        } else {
            self.setupCompactView()
        }
    }
}

Every time the size class changes we clean up and rebuild the view hierarchy:

  1. We remove every subview from the content stack because the order of the views differ. To avoid any issues we start from scratch.
  2. The content stack is removed from the header, which also removes any constraints we added earlier between the content and the header. We also clear out the active constraints array.
  3. Finally we call the correct setup method depending on the active size class. With this approach it is imperative we reset all the necessary properties on the views when reusing them.

We are nearly done with the header! As the last thing let’s create an update method to populate the views with our view model:

func update(viewModel: Album.Header.ViewModel) {
    self.coverView.image = viewModel.coverImage
    self.titleLabel.text = viewModel.title
    self.artistLabel.text = viewModel.artistName
    self.genreLabel.text = viewModel.genre
    self.descriptionLabel.text = viewModel.description
}

Measuring header view

The final thing we need to do in this example is to assign the header to the table view and set the header’s height. We return to the root view controller and start by creating an instance of the header inside the viewDidLoad method:

override func viewDidLoad() {
    super.viewDidLoad()
    
    let header = AlbumHeaderView()
    header.update(viewModel: self.viewModel.header)
    self.tableView.tableHeaderView = header

    // table view added to the view hierarchy here
}

Next we measure the header’s size at the end of the viewDidLayoutSubviews method, outside of the existing if !self.isInitialized block. We want to size the header every time the view controller updates its layout, not only once:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    if !self.isInitialized {
        ...
    }
    
    // Measure the size of the header
    if let headerView = self.tableView.tableHeaderView as? AlbumHeaderView {
        // (1)
        let sizeToFit: CGSize = CGSize(width: headerView.bounds.width, height: 0)

        // (2)
        let layoutSize: CGSize = headerView.systemLayoutSizeFitting(sizeToFit, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)

        // (3)
        if headerView.frame.size.height != layoutSize.height {
            var frame: CGRect = headerView.frame
            frame.size.height = layoutSize.height
            headerView.frame = frame
            self.tableView.tableHeaderView = headerView
        }
    }
}
  1. We create CGSize struct to provide the size to which the header should fit. The width uses the header's width (that is set by the table view for us), while the height is set to zero. That means we want the receive the smallest height the header is able to fit in.
  2. We use the systemLayoutSizeFitting method on the header view to obtain the layout size. The horizontal fitting priority is set to required to prevent the header from sizing itself more than the sizeToFit.width specifies. The vertical fitting priority of fittingSizeLevel will allow the header to take on as much height as it needs.
  3. When the calculated height is different from the current one we update the header's frame and re–assign the view to the table to make it refresh the header's height.

We can build and run the project to finally see our complete layout!

When using this approach of sizing the header it's important to initialize and constrain the header's subviews before calling the systemLayoutSizeFitting method.

Source code

Complete source code for this and all the other examples is available on GitHub.

Where to next

  1. Intro
  2. Basics, Part One
  3. Basics, Part Two
  4. Xcode Setup
  5. Sign Up Screen
  6. Stack Views
  7. Custom UIAlert
  8. Players Profile
  9. Twitter Timeline
  10. Twitter Profile
  11. Music Album (reading now)