Two Takes on Adaptive Cell Layout

I ran into a problem at work recently, where I had to create different layouts for different screens sizes in a UITableViewCell. It seemed like a simple enough task to do in Interface Builder, where the UITableViewCell was created originally. As I went deeper and deeper into this tiny xib file, I found myself struggling, so I took a step back, made some tea, watched a WWDC video, and read some docs. I ended up moving the whole thing to code, because of a mysterious layout problem I was unable to untangle in Interface Builder.

During these two takes, creating the same UI in Interface Builder, and in code, I learned some things, and compiled some conclusions worth sharing. We’ll talk about size classes, dealing with layout in Interface Builder, and in code. Debugging layout issues, and some thoughts on why I always seem to end up getting rid of stack views.

Traits

In the WWDC video linked above, Kevin Cathey talks about how traits describe the environment our application is running in. Traits are there to make it easier for us developers to work with the over 300 possible different screen size combinations one can support with an iOS app.

Creating adaptive layouts provides a better experience for our users. The first step of doing so is to look at what layouts we would like to support in our app.

Size classes

A size class is a trait. As Kevin calls it, "a layer of indirection". He explains, layers of indirection always describe some higher order behavior, that is in this case, experience, or how we are presenting information.

With these traits, we can reduce the complexity from 300 combinations to four, then two. What we care about now is the following:

Regular x Compact (R x C) and Regular x Regular (R x R), which should have the same layout as Compact x Compact (C x C) in our case.

trait_collections

From the UITraitCollection documentation ☝️

Knowing that, we can now associate device and device orientation combinations with one of these two classes. What we essentially want to do here, is to make our app’s layout respond to this external change properly. The best way of dealing with that is by trusting Auto Layout doing its job, and dynamically resolve whatever size class the app is running in. To achieve that, we’ll only need to define the proper set of constraints. 📐

C x C
Looks good in portrait

R x C
Looks good in portrait

The desired cell layouts.

Using Interface Builder

I was hesitant when I saw the file I needed to change is not a Swift, nor Objective-C file, but a xib file. The reason for that is that I just never tried doing such things in IB before, and I’d also naturally write UI in code when starting from scratch. In this case, I started by staring at IB for a few minutes and then just clicking buttons following my intuition; that only resulted in a huge mess and then a series of big reverts.

My initial frustration was that I already knew how to achieve what I wanted in code. Now I was facing this difficulty, having to learn this new tool to get to the same results, but without the simplicity and clarity I’m used to. I’m working on a team though, and I do respect people’s choice of tools, so I started reading the docs, and eventually ended up on this fantastic SO post describing the necessary steps (so many!) to get what I wanted working.

I did get it working (see project), except for the detail label's text alignment. I was unable to make it vary based on traits in IB for some reason, so implemented that in code.

Looks good in portrait

Looks good in landscape

Great! Building and running the app, I saw the cells showing up with the correct size and the content positioned correctly in both orientations. Let’s put this project away for a bit, and fire up a brand new one to see how to build this in code! 💻

Handling traits in code

We can make the UIStackView adapt to these changes simply by changing its axis whenever the trait collection changes on the cell.

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)

    if self.traitCollection.horizontalSizeClass == .compact &&
      self.traitCollection.verticalSizeClass == .regular {
      // iPhone portait (RxC)
      self.stackView.axis = .vertical
    } else {
      // iPad (RxR), iPhone landscape (CxC)
      self.stackView.axis = .horizontal
    }
  }

Awesome! Building and running will lead us to the same results we achieved previously with IB (without the blood and tears).You can check out the example project on GitHub.

Debugging (layout)

Up until now, we haven’t really tested the results. Giving it a spin, we’ll quickly see there are things that aren’t looking perfect.

Broken layout

Duh. Seems like the layout breaks on rotation. No warnings on Xcode debug console. Looking at Reveal, can't see anything suspicious.

reveal

No warnings in Reveal. sigh

We’ll need to find the problem ourselves. 🛠

By now, we have two projects at hand, with the ”same” app running, so we should decide in which project should we start looking. If I were sitting next to you now, I’d probably tell you “well, good luck figuring it out in Interface Builder!”. But why does that sound so difficult, isn’t IB what makes it easier to work on UI?

I don’t think so.

By looking at layout in code, we’re able to see whether our constraints do what we want them to do. They are simple lines of code, one level of abstraction over the logic defining the layout. We can literally see the linear equations they translate to. It’s straightforward for the brain to understand, and analyze the solution written down. 🧠

On the other hand, when trying to debug layout problems in Interface Builder, we have to get our brains to separate the logic of layout constraints from the visuals we are seeing. It tricks us into thinking using Interface Builder makes it easier, because we can see how our layout looks like in a GUI. But the truth is, what we are actually seeing there is our layout at a point in time, or a single frame. 🤯 Yes, one can switch between previews of all kinds of devices and orientations, but that just takes us farther and farther away from the solution.

My two cents:

I believe this is part of why people think Auto Layout is “hard”, “complex”, or even "the worst”. What I think many of them are actually going through is the difficulty of working with Auto Layout by adding a pretty thick layer of abstraction to it by using it from Interface Builder. Many people disagree, though.

Going back to our example, I switched back to code, and followed my usual procedure of debugging a problem:

  1. Have a clear idea of expectations.
  2. Have a clear idea of what’s happening instead.
  3. Isolate pieces that are working properly (providing expected results).
  4. Thin down the problem to one component.
  5. Try to understand how it works.
  6. Remove its dependencies, move it out to a mocked, clean environment.
  7. Rip out everything that’s unrelated to the faulty behavior.
  8. If it’s something custom, try to replace with built-in component, or functionality.
  9. Replace built-in component.
  10. File a bug with Apple and wait for them to fix.

Most of the time following these steps, I would probably find the solution at #2, and around #7 on bad days. In this case, I went all the way down to #9.

When analyzing the layout code with this method, I was able to see clearly what my expectation was: for the cell to maintain the original height per size class, even after orientation changes. I could also clearly see what was happening instead: my layout was breaking on rotation, hence the constraints were not getting satisfied. I thinned it down to one UIStackView, and read more of its documentation. Still not understanding what was happening, I moved it out to a brand new test project, to see if it breaks naturally, outside the context of my application. It did. It was a simple UIStackView, so there was no way to make it less custom. I got rid of it and replaced it with simple constraints.

By defining different constraints for different traits, we manually define both our layouts. Following that logic, we’ll see how simple it is to model this in code. We create two sets of constraints, and just activate or deactivate them when trait changes occur.

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    self.updateLayout()
 }
  
// Activates and deactives constraints based on size classes
private func updateLayout() {
  switch self.traitCollection.horizontalSizeClass {
  case .regular:
    // iPad
    NSLayoutConstraint.deactivate(self.regularCompactConstraints)
    NSLayoutConstraint.activate(self.regularRegularConstraints)
  case .compact:
    switch self.traitCollection.verticalSizeClass {
    case .regular:
      // iPhone portait
      NSLayoutConstraint.deactivate(self.regularRegularConstraints)
      NSLayoutConstraint.activate(self.regularCompactConstraints)
    case .compact:
      // iPhone landscape
      NSLayoutConstraint.deactivate(self.regularCompactConstraints)
      NSLayoutConstraint.activate(self.regularRegularConstraints)
    default: break
    }
  default: break
  }
}

🥁

Correct layout

BOOM 💥 Fixed!

Stack views are (awkwardly) special

For the longest time, I thought UIStackView was just a bunch of convenient pre-set Auto Layout constraints, to help people type up (or wire up) the most common layouts easily. It kept biting back though, which led me to suspect it is not that simple after all. A couple of interesting examples of stack view weirdness are backgroundColor and isHidden. They have different meanings from what we're used to. When you set the backgroundColor on a stack view, it will not change. When you set isHidden to true on an arranged subview of a stack view, it will shrink along the axis, whereas on any other UIView instance it would not change the view's layout.

Think about how implementing such specific behavior in a codebase can make logic more complicated, by introducing new state, side effects, and possibly, hacks. Changes like this can make code more error prone, and just more difficult to understand to begin with.

To demonstrate this effect, let’s stay with the isHidden example for a bit. Imagine the theoretical scenario of how we would change that behavior for UIView, but only if it’s an arranged subview of a UIStackView.

Let’s say that in our version of UIView, all the original implementation does is set the alpha to 1 or 0 whenever the hidden flag was flipped. It does not change anything with regards to its layout.

var isHidden: Bool {
  didSet {
    self.alpha = isHidden ? 0.0 : 1.0
  }
}

Now imagine changing this behavior in case the superview is an instance of UIStackView, and our view is an arranged subview of it.

var isHidden: Bool {
  didSet {
    // Identify if this UIView is an arranged subview of a UIStackView
    if let stackView = self.superview as? UIStackView,
      stackView.arrangedSubviews.contains(self) {
      // Update layout, animated
      UIView.animate(withDuration: 0.3, animations: { 
        if stackView.axis == .vertical {
          self._heightConstraint.constant = self.isHidden ? 0 : self.intrinsicContentSize.height
        } else {
          self._widthConstraint.constant = self.isHidden ? 0 : self.intrinsicContentSize.width
        }
        self.setNeedsLayout()
        self.layoutIfNeeded()
      })
    } else {
      // Original implementation
      self.alpha = isHidden ? 0.0 : 1.0
    }
  }
}

We’ve introduced a bunch of one-off logic to something that used to be simple with one clear side effect. Hiding used to be an effect on the UIView’s color, and now it’s changing its own, and its surrounding layout. Thinking about the impact of one distortion in behavior like this, deep down in a widely used property of a widely used class in our codebase should make us feel uncomfortable about this change, because mitigating the possibly introduced bugs would be a tremendous effort.

I’d love to see the implementation of UIStackView one day, to understand exactly what things it is doing differently and why, but in the meantime, I’ll keep replacing it at the first sign of problems. Right now, it’s a black box to me that makes my UIView instances behave differently than I expect them to behave. 🤷

Remember, the fewer abstractions we have over our layout code or really, any code in general, the easier it is to understand, and find problems in it.


Thanks to my dear friends for reviewing this article. 💛 If you have any questions, or feedback - which is very welcome, please leave comments, or reach out to me on Twitter. My DMs are open, too. 📨

Read more

Show Comments