Scrollable UIStackView

In case you've ever struggled with making your UIStackView look good in a UIScrollView, this post is here to explain why it's a bit tricky, and how to make them work together. ✨

This is a story about Auto Layout, scroll views, and how understanding a basic set of rules goes a long way in this case. No more warnings about ambiguous layouts, but nice and simple UI code that you can be proud of.

If you're not confident with Auto Layout, or just wanted to refresh your memories, here's an article I wrote a few months back. It tells you about the five things to understand about Auto Layout to master this powerful tool.

UIScrollView and Auto Layout

First of all, let's have a look at how UIScrollView works when using it with Auto Layout. The reason for dedicating a massive section for this topic is because scroll views are special, and so this is most of what needs explanation here.

To support scroll views, the system interprets constraints differently, depending on where the constraints are located.

From the Apple Auto Layout documentation on how to lay out scroll views. Fantastic read, highly recommended.

What the documentation is trying to tell us, is that when it comes to scroll views, there's not just one, but two sets of constraints to define, one for the frame, and one for the content. Two sets of constraints to define size and position. Which is not obvious, but if you go through it step-by-step once, you will always understand how a scroll view is layed out, or why it can't be layed out.

Constraining the scroll view and its contents

The two sets of constraints we'll talk about are the constraints for the scroll view's frame, and the constraints for its scrollable content.

"For constraints between the scroll view and its content, the behavior varies depending on the attributes being constrained."

Sounds a bit vague? 🤨 Hang in there.

The frame constraints

We want to have a full-screen UIScrollView. Here are the constraints for its frame to make it "stick" to its superview:

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

But how do we know these are the frame's constraints?

"Any constraints between the scroll view and objects outside the scroll view attach to the scroll view’s frame, just as with any other view."

Since self.view is the superview of scrollView, we know that we do have the constraints for the frame defined. ✅

Another option would be to express the position and size of the scroll view (frame) by defining explicit height, width, or center constraints for it.

Constraints between the height, width, or centers attach to the scroll view’s frame.

The content constraints

Let's move on to see how to create constraints for the scrollable content inside. We'll start by adding a nice green view as a subview (content) to the scrollview.

let greenView = UIView()
greenView.bakcgroundColor = .green

scrollView.addSubview(greenView)

We want it to stretch out to cover the whole area of the scroll view, so let's constrain its edges to the scroll view.

greenView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
  greenView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
  greenView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
  greenView.topAnchor.constraint(equalTo: scrollView.topAnchor),
  greenView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor)
])

"Constraints between the edges or margins of the scroll view and its content attach to the scroll view’s content area."

Meaning, doing that will indeed tell the system about how our content wants to be layed out.

Results are... Quite disappointing, though, when running the app.

blank_screen

Let's have a look at what our debugger says. Our debugger in this case, is a visual debugger called Reveal, a highly recommended tool for UI development.

Reveal_warning

Well, apparently, the size of the scrollable content is ambiguous. Meaning, we haven't defined enough constraints for the Auto Layout engine to be able to resolve the size. Why is that? Because what we defined is, that whatever the size of the greenView is, scrollView will stay attached to its edges. But we haven't said anything about how big the green view should be! 🤦🏻‍♀️

If your content does not have an intrinsic content size, you must add the appropriate size constraints, either to the content view or to the content.

Right. Let's add a big constant height constraint, because the green view obviously doesn't have intrinsic content size. We want our scroll view to be a long, vertically scrolling green thing!

greenView.heightAnchor.constraint(equalToConstant: 2000).isActive = true

Running it, it's still blank. Looking at Reveal, it tells us why.

width_warning-2

Because even though we want a vertically scrolling scroll view, we never told Auto Layout about it.

To disable horizontal scrolling, set the content view’s width equal to the scroll view’s width. The content view now fills the scroll view horizontally.

Same goes for disabling vertical scrolling, but the other way around. Anyways, let's make our scroll view vertically scrollable by adding this one extra constraint.

greenView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true

green_view

Voilà! Also, no warnings in Reveal. 🎊

UIStackView, scrolling

So what's the big deal about scrolling UIStackViews, now that we know all about how to set up a UIScrollView to scroll any kind of content by defining a few Auto Layout constraints.

The answer is: not much. UIStackViews with arranged subviews of either manually constrained, or intrinsic content size will behave exactly as our green view previously.

Let's build a stack view of UILabels alternating with green views to see how it works. 👩🏻‍🔧

Setting up the stack view with exactly the same constraints, except we're not providing a height here - we want it to "grow" vertically, by adding content to it.

let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 0
stackView.distribution = .fill
scrollView.addSubview(stackView)

stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
  // Attaching the content's edges to the scroll view's edges
  stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
  stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
  stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
  stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),

  // Satisfying size constraints
  stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
])

Now, we'll have to add some arranged subviews to the stack.

for i in 0...20 {
  let greenView = UIView()
  greenView.backgroundColor = .green
  stackView.addArrangedSubview(greenView)
  greenView.translatesAutoresizingMaskIntoConstraints = false
  // Doesn't have intrinsic content size, so we have to provide the height at least
  greenView.heightAnchor.constraint(equalToConstant: 100).isActive = true

  // Label (has instrinsic content size)
  let label = UILabel()
  label.backgroundColor = .orange
  label.text = "I'm label \(i)."
  label.textAlignment = .center
  stackView.addArrangedSubview(label)
}

If you take a look at it, you can see that we set the greenView's height constraint manually. This is necessary, because that simple view does not have intrinsic content size, so we want to make sure it has a height defined in the vertical stack view.

The label on the other hand has intrinsic content size, so that item will take care of its height. The width of the stack view is constrained to the scroll view's width, so it should stretch out to "full screen".

Let's see how it looks like after running it.

green_and_label_full_screen

Neat! 💖

You can find the source code of this example on my GitHub, with comments.

Finale

Now if you wanted to see a real life application of scrollable stack views, have a look at this screen I built a few weeks ago:

orders

It's a stack view in a scroll view, that has a bunch of custom arranged subviews in it. All built up with Auto Layout from code, following the same principles that we went through above, in order to achieve a satisfiable, non-ambiguous layout. 👏🏻

Closing thoughts

Wrapping up, I wanted to highlight a few important things to take away.

Auto Layout 💕

I'm pretty much an advocate of Auto Layout at this point, because in my experience, it only seems like there's a higher bar to learn how to work with it. In reality, there's well-written documentation online, and my hope is that showing some more detailed examples with visual results step-by-step will help others get through the initial struggle, and learn to love this well-designed, but simple system that I find an incredible piece of engineering.

Good debugging tools are crucial

Great debugging tools are crucial to learn how things work and to speed up the process when in the phrase of figuring things out, or resolving issues. In this case especially, because for what you're working on you need a very good visual model in your mind otherwise, which is hard. As I mentioned before, I use Reveal for this purpose. There's always some cool feature I learn about on the way that makes my life easier.

Feedback

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. 📨

Show Comments