Coding Auto Layout by Example — Players Profile

Posted: January 19, 2022

Player Profiles

In this example we are going to build a simple view to display some basic information about a football (soccer) player. We’ll look at how we can nest stack views, use stack view layout margins and react to size changes of the view controller to update our layout with a more fine–grained approach when size classes are not enough.

Overview

The player profile view is a vertical stack that contains a header and a boxed content view. The header itself is a horizontal stack view. The content uses a horizontal stack to arrange multiple vertical stack views. Phew, quite a stackception! Let’s see how it’s done.

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

View model

Let’s define some view models first. In a new file Models.swift we’ll create a view model for the single title/value view:

import UIKit

enum TitledValue {
    struct ViewModel {
        let title: String
        let value: String
    }
}

Then a view model for the entire player’s profile view that contains the player’s name and country image, along with a list of values:

enum PlayerProfile {
    struct ViewModel {
        let country: UIImage?
        let name: String
        let values: [TitledValue.ViewModel]
    }
}

Finally we create a sample model with two famous football players:

enum SampleModel {
    static let player1: PlayerProfile.ViewModel = PlayerProfile.ViewModel(country: UIImage(named: "portugal"), name: "Cristiano Ronaldo", values: [
        TitledValue.ViewModel(title: "Age", value: "36"),
        TitledValue.ViewModel(title: "Seasons", value: "20"),
        TitledValue.ViewModel(title: "Games", value: "1097"),
        TitledValue.ViewModel(title: "Goals", value: "802")
    ])
    
    static let player2: PlayerProfile.ViewModel = PlayerProfile.ViewModel(country: UIImage(named: "argentina"), name: "Lionel Messi", values: [
        TitledValue.ViewModel(title: "Age", value: "34"),
        TitledValue.ViewModel(title: "Seasons", value: "18"),
        TitledValue.ViewModel(title: "Games", value: "951"),
        TitledValue.ViewModel(title: "Goals", value: "758")
    ])
}

Titled value view

To build a more complex view, it’s often a good idea to break it down into smaller subviews. In our case the profile view is composed of multiple smaller views, here’s the breakdown:

Player Profiles

We’ll start with the view that’s deepest in the hierarchy — the titled value view. It’s a simple stack view with a title and value labels. Additionally it contains a distinct background for the title and a horizontal border through the middle.

Let’s create a new subclass of UIStackView:

class TitledValueView: UIStackView {

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

    private func setupView() {

    }

}

We add a title and value labels as member variables to be able to update the view whenever we want:

class TitledValueView: UIStackView {
    
    private let titleLabel: UILabel = UILabel()
    private let valueLabel: UILabel = UILabel()

    ...

}

In the setupView method we first initialize the stack view’s properties like the axis, spacing and also add some margins:

private func setupView() {
    self.axis = .vertical
    self.spacing = 16
    self.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
    self.isLayoutMarginsRelativeArrangement = true
}

When we add margins to a stack view and enable the isLayoutMarginsRelativeArrangement property, the stack view will automatically add spacing between the arranged subviews and its bounds:

Player Profiles

Before we add any of the labels, we want to create the background view first to make it appear behind the labels. Note we add the background using the addSubview method, not addArrangedSubview. This way we can manually constrain the background without affecting the stack’s layout:

private func setupView() {
    ...

    let backgroundView = UIView()
    backgroundView.translatesAutoresizingMaskIntoConstraints = false
    backgroundView.backgroundColor = .secondarySystemBackground
    self.addSubview(backgroundView)

    NSLayoutConstraint.activate([
        backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor), // (1)
        backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor), // (1)
        backgroundView.topAnchor.constraint(equalTo: self.topAnchor), // (1)
        backgroundView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.5) // (2)
    ])
}
  1. The leading, trailing and top anchors are pinned to the matching anchors of the stack view. This way the background starts at the top and spans across the entire width of the stack.
  2. The height is set to half of the stack view. This in combination with the other constraints will make the background cover the entire top half of the stack.

Next we add the border view separating the title and value labels. Once again the view is added using the addSubview method so that we can manually set its layout:

private func setupView() {
    ...
    
    let borderView = UIView()
    borderView.translatesAutoresizingMaskIntoConstraints = false
    borderView.backgroundColor = .separator
    self.addSubview(borderView)
    
    NSLayoutConstraint.activate([
        borderView.centerXAnchor.constraint(equalTo: self.centerXAnchor), // (1)
        borderView.centerYAnchor.constraint(equalTo: self.centerYAnchor), // (1)
        borderView.widthAnchor.constraint(equalTo: self.widthAnchor), // (2)
        borderView.heightAnchor.constraint(equalToConstant: 1) // (3)
    ])
}
  1. Both the horizontal and vertical centers match that of the stack view.
  2. The width is set to match the stack view as well.
  3. The height is set to constant of 1, creating a nice horizontal line.

Finally we get to create the labels. We set their text alignment to center since the stack view will size them automatically to full width. In case the text within the labels gets longer than the available width we allow for the font to made smaller:

private func setupView() {
    ...
    
    // Title label
    self.titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
    self.titleLabel.textAlignment = .center
    self.titleLabel.minimumScaleFactor = 0.5
    self.titleLabel.adjustsFontSizeToFitWidth = true
    self.addArrangedSubview(self.titleLabel)
    
    // Value label
    self.valueLabel.font = UIFont.preferredFont(forTextStyle: .body)
    self.valueLabel.textAlignment = .center
    self.valueLabel.minimumScaleFactor = 0.5
    self.valueLabel.adjustsFontSizeToFitWidth = true
    self.addArrangedSubview(self.valueLabel)
}

Lastly we create an update method to be able to update the view with our view model:

func update(viewModel: TitledValue.ViewModel) {
    self.titleLabel.text = viewModel.title
    self.valueLabel.text = viewModel.value
}

Boxed values view

We can now move on to another piece of the puzzle, the boxed value view that will make use of the TitledValueView and arrange those horizontally.

Start by creating a new file BoxedValuesView.swift and adding a subclass of UIView:

class BoxedValuesView: UIView {

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

    private func setupView() {

    }
}
BoxedValuesView does not subclass UIStackView directly because we want to display a border around the view. Stack views support background colors, borders and other CALayer properties only since iOS 14.

Next we add the content stack view as a member property:

class BoxedValuesView: UIView {

    private let contentStackView = UIStackView()

    ...
}

In the setupView method we set the border style:

private func setupView() {
    self.translatesAutoresizingMaskIntoConstraints = false
    self.layer.borderWidth = 1
    self.layer.borderColor = UIColor.separator.cgColor
    self.layer.cornerRadius = 4
}

We then initialize the stack view and set its constraints. We use horizontal arrangement with equal sizing distribution to ensure all our titled value views have the same width. The constraints simply pin all of the stack view’s edges to the superview:

private func setupView() {
    ...

    self.contentStackView.axis = .horizontal
    self.contentStackView.distribution = .fillEqually
    self.contentStackView.translatesAutoresizingMaskIntoConstraints = false
    self.addSubview(self.contentStackView)

    NSLayoutConstraint.activate([
        self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
        self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
        self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor),
        self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
    ])
}

Now we can add an update method to have the stack refresh with our view model:

func update(viewModels: [TitledValue.ViewModel]) {
    // (1)
    for view in self.contentStackView.subviews {
        view.removeFromSuperview()
    }
    
    for (index, viewModel) in viewModels.enumerated() {
        // (2)
        let view = TitledValueView()
        view.update(viewModel: viewModel)
        self.contentStackView.addArrangedSubview(view)
        
        // (3)
        if index > 0 {
            let border = UIView()
            border.translatesAutoresizingMaskIntoConstraints = false
            border.backgroundColor = .separator
            self.contentStackView.addSubview(border)
            
            // (4)
            NSLayoutConstraint.activate([
                border.centerXAnchor.constraint(equalTo: view.leadingAnchor),
                border.widthAnchor.constraint(equalToConstant: 1),
                border.topAnchor.constraint(equalTo: self.contentStackView.topAnchor),
                border.bottomAnchor.constraint(equalTo: self.contentStackView.bottomAnchor)
            ])
        }
    }
}
  1. We remove any existing views in the stack. Note we are using the subviews property, because the border view is not part of the arrangedSubviews property (see below).
  2. We loop over the view models, create TitledValueView for each one and add it to the stack view as an arranged subview.
  3. After the first view we also start adding a border to the left side of the view. Note the border must not be added as an arranged subview, but via the regular addSubview method.
  4. The border has its horizontal center aligned with the leading edge of the titled value view. Its width is set to a constant of 1 while top and bottom anchors are pinned to the stack view, creating a vertical border.

Country / name view

Next view is the header that displays the player’s country flag and name. In a new file CountryNameView.swift we add a subclass of UIStackView:

class CountryNameView: UIStackView {

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

    }

}

Then we add the image view and title label as member properties:

class CountryNameView: UIStackView {
    
    private let imageView = UIImageView()
    private let titleLabel = UILabel()

    ...

}

In the setupView method we initialize the stack view’s properties, this time we only need to set the axis and spacing:

private func setupView() {
    self.axis = .horizontal
    self.spacing = 16
}

The image view also doesn’t require lengthy setup and can be added directly to the stack view:

private func setupView() {
    ...

    self.imageView.contentMode = .scaleAspectFit
    self.addArrangedSubview(self.imageView)
}

For the title it’s important we adjust the content hugging and compression resistance priorities. We want stretch the label instead of the image to fill any available layout space. Similarly we want to avoid squeezing the image view so we lower the compression resistance for the label instead:

private func setupView() {
    ...

    self.titleLabel.font = UIFont.preferredFont(forTextStyle: .title2)
    self.titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
    self.titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    self.titleLabel.adjustsFontSizeToFitWidth = true
    self.titleLabel.minimumScaleFactor = 0.5
    self.addArrangedSubview(self.titleLabel)
}

And that’s it! We just need to an update method and we are done:

func update(image: UIImage?, name: String) {
    self.imageView.image = image
    self.titleLabel.text = name
}

Player profile view

Now we need to put all these views together to compose our complete view. This view will contain the header and the boxed view and arrange them vertically. We create a new file PlayerProfileView.swift and add a new subclass of UIStackView.

class PlayerProfileView: UIStackView {

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

    }

}

We set the stack view’s properties, specifically the axis, spacing and alignment.

private func setupView() {
    self.axis = .vertical
    self.alignment = .center
    self.spacing = 8
}

Since the alignment is set to center, the stack view will not resize its arranged subviews automatically, instead it will keep them at their intrinsic size. However, we want the boxed view to fill the stack view fully so we’ll add the width constraint for that. As always we must not forget to add the view to the hierarchy first:

private func setupView() {
    ...
    
    self.addArrangedSubview(self.countryNameView)
    self.addArrangedSubview(self.boxView)
    
    self.boxView.widthAnchor.constraint(equalTo: self.widthAnchor).isActive = true
}

Once again we add an update method, this time accepting the PlayerProfile.ViewModel struct:

func update(viewModel: PlayerProfile.ViewModel) {
    self.countryNameView.update(image: viewModel.country, name: viewModel.name)
    self.boxView.update(viewModels: viewModel.values)
}

And that’s all there is to it! Now we can go back to the root view controller and add the view to the screen.

Displaying player profile views

We want to display a profile view for every view model we have and we’ll use another stack view to arrange the views. Back in the view controller file we add it as a member property:

class RootViewController: UIViewController {
    
    private let contentStackView = UIStackView()

    ...

}

We initialize the spacing and set the constraints in the viewDidLoad method. We are not setting the axis or distribution just yet, we’ll get to that in a moment:

override func viewDidLoad() {
    super.viewDidLoad()
    
    self.contentStackView.spacing = 16
    self.contentStackView.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(self.contentStackView)
    
    NSLayoutConstraint.activate([
        self.contentStackView.leadingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.leadingAnchor), // (1)
        self.contentStackView.trailingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.trailingAnchor), // (1)
        self.contentStackView.centerYAnchor.constraint(equalTo: self.view.layoutMarginsGuide.centerYAnchor) // (2)
    ])
}
  1. The leading and trailing anchors are pinned to the layout margins guide of the view controller. This sets the horizontal position as well as the width of the stack view.
  2. The vertical center is aligned with the layout margins guide as well. The height of the stack is not constrained in any way in this example and is allowed to grow as much as it needs to.

After that we create the two profile views and add them to the stack view:

override func viewDidLoad() {
    ...

    let player1View = PlayerProfileView()
    player1View.update(viewModel: SampleModel.player1)
    self.contentStackView.addArrangedSubview(player1View)

    let player2View = PlayerProfileView()
    player2View.update(viewModel: SampleModel.player2)
    self.contentStackView.addArrangedSubview(player2View)
}

Now imagine we want the two profile views arranged horizontally whenever there is enough horizontal space available to do so, otherwise they should be stacked vertically. How can we do that? Previously we used size classes which is the system’s way of saying "you either have a lot of space or not as much space available for your layout in the given dimension". While those work for most use cases and greatly simplify our layout code, sometimes we want more control. For example in this example we could still arrange the two profile views horizontally on smaller iPhones when holding them in landscape:

Player Profiles

If we used size classes, some iPhones would report compact for the horizontal size class, indicating we have a limited space available. However we know what our layout looks like and want to do it our way. So instead of querying the current size class, we check the view controller’s size and decide on a threshold value (for the width) after which the content stack should arrange the views horizontally. For example, whenever the width of the view controller is more than 500 points use horizontal arrangement, otherwise stick to vertical.

Let’s create a new method in the view controller to describe this behavior:

private func setupView(for size: CGSize) {
    if size.width > 500 {
        self.contentStackView.axis = .horizontal
        self.contentStackView.distribution = .fillEqually
    } else {
        self.contentStackView.axis = .vertical
        self.contentStackView.distribution = .fill
    }
}

Whenever the provided size object reports 500 or more for the width, we set the axis to horizontal and apply equal sizing, otherwise we fallback to the vertical arrangement. All that’s left now is to call this method at some point. Luckily view controllers report any changes in the view’s size via the viewWillTransition(to:with:) method which we can override easily:

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

We use the provided size parameter and pass it over to our setupView method, changing our stack view’s orientation as needed. This handles any changes made to the size of the view controller (such as when the device orientation changes), however this method may not be called when the view controller is initialized and we need to address that.

It may sound like a good idea to call the setupView method from within the viewDidLoad method, however the size of the view controller’s view may not always be correctly set just yet (the view is still not part of the view hierarchy at this point) and we should not rely on its size here. Instead we need to wait for the view to become part of the view hierarchy. We can do that in multiple methods inside the view controller, one of them is viewWillLayoutSubviews. Since this method may be called multiple times during the lifecycle of the view controller, we should add a boolean flag to make sure the setupView method is only called once during the very first layout pass:

private var isInitialized: Bool = false

...

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    
    if !self.isInitialized {
        self.isInitialized = true
        
        self.setupView(for: self.view.bounds.size)
    }
}

Here we can safely query the view’s size and call the setupView method to have the content stack initialized. Run the project to see the two views being arranged horizontally even on smaller phones!

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 (reading now)
  9. Twitter Timeline
  10. Twitter Profile
  11. Music Album