Refactoring a Multiple State View Using the UIStackView

iOS refactoring

Written by Nicholas Steppan Meschke, Software Engineer iOS

What awaits you

As developers who care about the user experience of our app, we’ll likely encounter some scenarios that require a view to reflect different states. Sometimes, the state of the view may even impact its size. The code that handles all those transitions can quickly become a mess. In this article, we want to share our approach to implementing such scenarios using UIStackView.

Let’s start with an example

We use a simple app idea to demonstrate how we can achieve that. We already have a demo app that has a simple button, which increases a counter when tapped. The number of taps is displayed on the screen. To make the demo app more interesting, we’ll add functionality to set and track tapping goals!

Base layout

We can now set goals! Here’s the code that’s responsible for setting up the constraints for the empty tapping goal view.

private func setupEmptyGoal() {
  // … code left out
  NSLayoutConstraint
  .activate([emptyGoalTitleLabel.topAnchor.constraint(equalTo: emptyGoalView.topAnchor, constant: 16),
    emptyGoalTitleLabel.trailingAnchor.constraint(equalTo: emptyGoalView.trailingAnchor, constant: -16),
    emptyGoalTitleLabel.leadingAnchor.constraint(equalTo: emptyGoalView.leadingAnchor, constant: 16)])
  NSLayoutConstraint
  .activate([optionsStackView.topAnchor.constraint(equalTo: emptyGoalTitleLabel.bottomAnchor, constant: 32),
    optionsStackView.trailingAnchor.constraint(equalTo: emptyGoalView.trailingAnchor, constant: -16),
    optionsStackView.leadingAnchor.constraint(equalTo: emptyGoalView.leadingAnchor, constant: 16),
    optionsStackView.bottomAnchor.constraint(equalTo: emptyGoalView.bottomAnchor, constant: -16)])
  NSLayoutConstraint
  .activate([emptyGoalView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
    emptyGoalView.bottomAnchor.constraint(equalTo: bottomAnchor),
    emptyGoalView.trailingAnchor.constraint(equalTo: trailingAnchor),
    emptyGoalView.leadingAnchor.constraint(equalTo: leadingAnchor)])
}

The code sets the constraints for the view in the EmptyGoal state. Cool, we now have the state where we don’t have a goal, so let’s create one for when a goal is set.

Goal set

Here’s the code responsible for creating and setting up view for when we have a goal set. Let’s call this state GoalSet.

private func setupGoalView() {
  // … code left out
  NSLayoutConstraint
  .activate([goalTitleLabel.topAnchor.constraint(equalTo: goalView.topAnchor, constant: 16),
    goalTitleLabel.centerXAnchor.constraint(equalTo: goalView.centerXAnchor)])
  NSLayoutConstraint
  .activate([progressLabel.topAnchor.constraint(equalTo: goalTitleLabel.bottomAnchor, constant: 16),
    progressLabel.centerXAnchor.constraint(equalTo: goalView.centerXAnchor),
    progressLabel.bottomAnchor.constraint(equalTo: goalView.bottomAnchor, constant: -16)])
  NSLayoutConstraint
  .activate([goalView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
    goalView.topAnchor.constraint(equalTo: goalSetLabel.bottomAnchor, constant: 16),
    goalView.bottomAnchor.constraint(equalTo: bottomAnchor),
    goalView.trailingAnchor.constraint(equalTo: trailingAnchor),
    goalView.leadingAnchor.constraint(equalTo: leadingAnchor)])
}

Awesome, now we can set a tapping goal and track it in our app. However, there’s one issue here: even though the spacing in our view in the GoalSet state should be 16pt, it appears to be a lot more. That’s weird, but let’s keep improving our app by introducing a Success state, for when we achieve our goal!

Success state

Here’s our Success state view and below, the code for it:

private func setupSuccessView() {
  // … code left out
  NSLayoutConstraint
  .activate([imageView.topAnchor.constraint(equalTo: successView.topAnchor),
    imageView.bottomAnchor.constraint(equalTo: successView.bottomAnchor),
    imageView.trailingAnchor.constraint(equalTo: successView.trailingAnchor),
    imageView.leadingAnchor.constraint(equalTo: successView.leadingAnchor)])
  NSLayoutConstraint
  .activate([successView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
    successView.topAnchor.constraint(equalTo: goalSetLabel.bottomAnchor, constant: 16),
    successView.bottomAnchor.constraint(equalTo: bottomAnchor),
    successView.trailingAnchor.constraint(equalTo: trailingAnchor),
    successView.leadingAnchor.constraint(equalTo: leadingAnchor)])
}

But what’s wrong?

Yay, now we have our 3 state views for our clicking goal app! But when we open our app, it looks like this:

Layout issues

Apart from looking really strange and stretched, we have a bunch of constraints that basically do the same thing: constrain the state views to our superView. How can we improve that?

UIStackView to the rescue!

UIStackViews are a really useful tool for laying out a collection of views in either column or row. In our case, we are going to use the UIStackView to group our state views, and it will adapt its size according to what is inside it. Our new code looks like this:

private func setupStackView() {
  // … code left out
  NSLayoutConstraint
  .activate([stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
    stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
    stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
    stackView.bottomAnchor.constraint(equalTo: bottomAnchor)])
}
private func setupSuccessView() {
  // … code left out
  NSLayoutConstraint
  .activate([imageView.topAnchor.constraint(equalTo: successView.topAnchor),
    imageView.bottomAnchor.constraint(equalTo: successView.bottomAnchor),
    imageView.trailingAnchor.constraint(equalTo: successView.trailingAnchor),
    imageView.leadingAnchor.constraint(equalTo: successView.leadingAnchor)])
}
private func setupGoalView() {
  // … code left out
  NSLayoutConstraint
  .activate([goalTitleLabel.topAnchor.constraint(equalTo: goalView.topAnchor, constant: 16),
    goalTitleLabel.centerXAnchor.constraint(equalTo: goalView.centerXAnchor)])
  NSLayoutConstraint
  .activate([progressLabel.topAnchor.constraint(equalTo: goalTitleLabel.bottomAnchor, constant: 16),
    progressLabel.centerXAnchor.constraint(equalTo: goalView.centerXAnchor),
    progressLabel.bottomAnchor.constraint(equalTo: goalView.bottomAnchor, constant: -16)])
}
private func setupEmptyGoal() {
  // … code left out
  NSLayoutConstraint
  .activate([emptyGoalTitleLabel.topAnchor.constraint(equalTo: emptyGoalView.topAnchor, constant: 16),
    emptyGoalTitleLabel.trailingAnchor.constraint(equalTo: emptyGoalView.trailingAnchor, constant: -16),
    emptyGoalTitleLabel.leadingAnchor.constraint(equalTo: emptyGoalView.leadingAnchor, constant: 16)])
  NSLayoutConstraint
  .activate([optionsStackView.topAnchor.constraint(equalTo: emptyGoalTitleLabel.bottomAnchor, constant: 32),
    optionsStackView.trailingAnchor.constraint(equalTo: emptyGoalView.trailingAnchor, constant: -16),
    optionsStackView.leadingAnchor.constraint(equalTo: emptyGoalView.leadingAnchor, constant: 16),
    optionsStackView.bottomAnchor.constraint(equalTo: emptyGoalView.bottomAnchor, constant: -16)])
}

Refactor

The layouts are now being shown according to their respective constraints, and our code looks simpler and easier to understand. By using the UIStackView, we swapped 3 addSubview calls for 1, and 12 layout constraints for only 4.

Wait, that’s not all!

Right now, we manage our states through 3 methods and to set our view state to the desired state, we need to call all of them, which looks something like this:

showGoal(false)
showEmptyGoal(false)
showSuccessView(true)

That’s not optimal for maintainability, and understanding the code could obviously be easier. To fix this, we can introduce a new Enum with values that represent our states. Our code should look like this:

enum GoalViewState {
    case empty, goal(value: GoalSize), success
}
enum GoalSize: Int {
    case small = 15, medium = 45, big = 100
    
    var text: String {
        return String(self.rawValue)
    }
}

Inside our view, we create a new property “state” of type GoalViewState. It’s responsible for storing and handling our state changes using didSet method. The resulting code looks like this:

var state: GoalViewState = .empty {
    didSet {
        updateState(with: state)
    }
}
private func updateState(with state: GoalViewState) {
    stackView.arrangedSubviews.forEach { $0.isHidden = true }
    goalSetLabel.isHidden = true
        
    switch state {
    case .empty:
        emptyGoalView.isHidden = false
    case .goal(let value):
        goalView.isHidden = false
        goalSetLabel.isHidden = false
        setGoal(value)
    case .success:
        successView.isHidden = false
    }
}

Ok, but what does it do and how does it work? Simple! If we need to change the state of our view, we can call “state = .goal(.small)”, making the code more readable and less error-prone, giving whoever reads it a better idea of what’s going on.

Wrapping up

UIStackViews are a quite powerful tool, but like other tools, they are not a silver bullet for every problem. Our constraint problem could have been fixed using a height constraint in the superview, however, that would require us to manually calculate the view size, which would make maintenance more difficult later on. Using a UIStackView, the size is inferred using its contents. This not only it gives us a convenient way to have our views correctly sized, but also is robust enough to deal with other requirements, such as showing two states at once. That’s it for today!

Have you ever used a UIStackView as we described in this article? Let us know in the comment section below!

***

RATE THIS ARTICLE NOW

Runtastic Tech Team We are made up of all the tech departments at Runtastic like iOS, Android, Backend, Infrastructure, DataEngineering, etc. We’re eager to tell you how we work and what we have learned along the way. View all posts by Runtastic Tech Team »