UIStackView
is a great showcase of Auto Layout – it is built entirely using constraints. It makes it fairly straightforward to build an open-source replacement. Even if you’re not in the business of building one, you may find this article useful because it will give you a better understanding of how UIStackView
works under the hood.
Here is my plan. I’ll create an instance of UIStackView
, try different configurations, take screenshots, and print the constraints affecting the stack view layout. With this information, re-implementing UIStackView
should be as easy as translating those constraints into code. I’ve already done that in a library named Arranged.
The code from this article is available at kean/Arranged.
Introduction #
UIStackView
is an abstraction on top of Auto Layout for laying out a collection of views in either a column or a row.
The stack view manages the layout of all the views in its arrangedSubviews property. These views are arranged along the stack view’s axis, based on their order in the arrangedSubviews array. The exact layout varies depending on the stack view’s axis, distribution, alignment, spacing, and other properties.
I’ll use a stack view with the three arranged views with the following intrinsic content sizes:
Intrinsic Content Size Subview0.width == 160 Hug:250 CompressionResistance:750 Subview0.height == 200 Hug:250 CompressionResistance:750 Subview1.width == 80 Hug:250 CompressionResistance:750 Subview1.height == 100 Hug:250 CompressionResistance:750 Subview2.width == 40 Hug:250 CompressionResistance:750 Subview2.height == 50 Hug:250 CompressionResistance:750
UIStackView.Distribution #
The distribution determines how the stack view lays out its arranged views along its axis. Let’s start with a default UIStackView
configuration, which is also one of the easiest to implement:
.fill #
A layout where the stack view resizes its arranged views so that they fill the available space along the stack view’s axis. When the arranged views do not fit within the stack view, it shrinks the views according to their compression resistance priority. If the arranged views do not fill the stack view, it stretches the views according to their hugging priority.
Constraints #
Let’s print a full list of constraints inside the stack view using this code:
constraints(for: stackView).forEach { print($0) }
func constraints(for view: UIView) -> [NSLayoutConstraint] {
var constraints = [NSLayoutConstraint]()
constraints.append(contentsOf: item.constraints)
for subview in item.subviews {
constraints.append(contentsOf: subview.constraints)
}
return constraints
}
The constraints get printed in a full format like this:
<NSLayoutConstraint:0x6100000922f0 'UISV-canvas-connection'
UIStackView:0x7f892be06190.leading == Subview0.leading
(active, names: content-view-1:0x7f892be0bb70 )>
As you can see stack view sets a specific identifier
based on the role that the constraint plays in the layout. Let’s convert the remaining constraints to a more concise format:
UISV-alignment: Subview0.top == Subview1.top Subview0.top == Subview2.top Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
Constraint Categories #
The first two categories of the constraints ensure that the subviews fill the stack view both horizontally (distribution .fill
) and vertically (alignment .fill
):
- UISV-alignment constraints align subviews horizontally by making them have equal
.bottom
and equal.top
layout attributes (NSLayoutAttribute
). Notice that the stack view pins all the subviews to the first one. - UISV-canvas-connection constraints connect subviews to the ‘canvas’ which is a stack view itself. The first and the last subview gets pinned to the
.leading
and.trailing
layout attributes of the stack view respectively. The first subview also gets pinned to the.top
and.bottom
attributes.
UISV-spacing constraints simply add the same spacing between each of the subsequent subviews. The spacing is defined by the value of the stack view spacing
property.
Intrinsic Content Size #
I haven’t defined the stack’s size along any of its axis. In this case, the stack view’s size grows freely in both dimensions, based on its arranged views. So the other remaining constraints that affect the stack view layout are intrinsic content size constraints which I’ve listed earlier. The stack view takes the height of the Subview0
which is the highest with Subview0.height == 200 Hug:250 CompressionResistance:750
. The width of the stack view is equal to the combined width of all subviews + all spacings.
As the result, you get a simple but very useful layout. In terms of constraints, this configuration is the simplest one to implement.
.fillEqually #
A layout where the stack view resizes its arranged views so that they fill the available space along the stack view’s axis. The views are resized so that they are all the same size along the stack view’s axis.
UISV-fill-equally: Subview1.width == Subview0.width Subview2.width == Subview0.width UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
This distribution is almost the same as fill distribution. The only different is the new UISV-fill-equally set of constraints. It forces all of the arranged views to have the same width (or height for vertical UIStackView
).
.fillProportionally #
A layout where the stack view resizes its arranged views so that they fill the available space along the stack view’s axis. Views are resized proportionally based on their intrinsic content size along the stack view’s axis.
UISV-fill-proportionally: Subview0.width == 0.571429*StackView.width priority:999 Subview1.width == 0.285714*StackView.width priority:998 Subview2.width == 0.142857*StackView.width priority:997 Hardcoded Width (Custom Constraint): StackView.width == 200 UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
This one is also similar to the fill distribution, but it requires a new UISV-fill-proportionally category of constraints:
UISV-fill-proportionally: Subview0.width == 0.571429*StackView.width priority:999 Subview1.width == 0.285714*StackView.width priority:998 Subview2.width == 0.142857*StackView.width priority:997
The multipliers (0.571429, 0.285714, etc) are calculated as a proportion of an arranged view intrinsic content width to the combined intrinsic content width of all visible arranged views + size of all spacings.
At first, this layout seems relatively simple to implement. But there are some potential issues. What happens when not all (or none) of the arranged views have an intrinsic content size? What happens when the intrinsic content size of some of the arranged views changes? Well, as to the last question, UIStackView
uses a neat private method _intrinsicContentSizeInvalidatedForChildView
in which it recalculates constraints’ multipliers. Unfortunately, you can’t do that, and there doesn’t seem to be any other way to implement this.
Also, notice that the priority of the constraints is higher than both content hugging and compression resistance priorities which both effectively get ignored (unless you change those).
I’m not sure why each constraint has a different priority (999, 998, etc). If you have an idea please leave a comment below.
.equalSpacing #
A layout where the stack view positions its arranged views so that they fill the available space along the stack view’s axis. When the arranged views do not fill the stack view, it pads the spacing between the views evenly. If the arranged views do not fit within the stack view, it shrinks the views according to their compression resistance priority.
UISV-distributing-edge: H:[Subview0]-(0)-[_UIOLAGapGuide:0x6080001dbc60] H:[_UIOLAGapGuide:0x6080001dbc60]-(0)-[Subview1.leading] H:[Subview1]-(0)-[_UIOLAGapGuide:0x6080001db8a0] H:[_UIOLAGapGuide:0x6080001db8a0]-(0)-[Subview2.leading] UISV-fill-equally: _UIOLAGapGuide:0x6080001db8a0.width == _UIOLAGapGuide:0x6080001dbc60.width UISV-spacing: H:[Subview0]-(>=10)-[Subview1] H:[Subview1]-(>=10)-[Subview2] UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom
This configuration requires extra spacers (_UIOLAGapGuide
) between subsequent subviews. Spacers are pinned to respected subviews by UISV-distributing-edge constraints. All the spacers have the same size thanks to UISV-fill-equally constraints. The UISV-spacing constraints are a bit different from previous distributions too - they now use NSLayoutRelation.greaterThanOrEqual
rather than NSLayoutRelation.equal
.
.equalCentering #
A layout that attempts to position the arranged views so that they have an equal center-to-center spacing along the stack view’s axis, while maintaining the spacing property’s distance between views. If the arranged views do not fit within the stack view, it shrinks the spacing until it reaches the minimum spacing defined by its spacing property. If the views still do not fit, the stack view shrinks the arranged views according to their compression resistance priority.
UISV-distributing-edge: _UIOLAGapGuide:0x6000001dc5c0.leading == Subview0.centerX _UIOLAGapGuide:0x6000001dc5c0.trailing == Subview1.centerX _UIOLAGapGuide:0x6000001dc2f0.leading == Subview1.centerX _UIOLAGapGuide:0x6000001dc2f0.trailing == Subview2.centerX UISV-fill-equally: _UIOLAGapGuide:0x6000001dc2f0.width ==_UIOLAGapGuide:0x6000001dc5c0.width priority:149 UISV-spacing: H:[Subview0]-(>=0)-[Subview1] H:[Subview1]-(>=0)-[Subview2] UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom
Equal centering distribution is similar to the .EqualSpacing because it also uses spacers (_UIOLAGapGuide
) between subsequent views. The main difference is how those spacers are used. Instead of pinning spacers to the respective .leading
and .trailing
layout attributes of the views they are now pinned to the .centerX
.
The other difference is that UISV-fill-equally constraints now have a low priority of 149, which is lower than the default compression resistance, which ensures that:
If the arranged views do not fit within the stack view, it shrinks the spacing until it reaches the minimum spacing defined by its spacing property. If the views still do not fit, the stack view shrinks the arranged views according to their compression resistance priority.
The minimum spacing is again achieved by UISV-spacing constraints which all have a required priority.
UIStackView.Alignment #
.fill #
A layout where the stack view resizes its arranged views so that they fill the available space perpendicular to the stack view’s axis.
UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
This configuration is exactly the same as the very first one: fill distribution.
.leading #
A layout for vertical stacks where the stack view aligns the leading edge of its arranged views along its leading edge.
UISV-alignment: Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-spanning-boundary: _UILayoutSpacer.top == Subview0.top priority:999.5 _UILayoutSpacer.bottom >= Subview0.bottom _UILayoutSpacer.top == Subview1.top priority:999.5 _UILayoutSpacer.bottom >= Subview1.bottom _UILayoutSpacer.top == Subview2.top priority:999.5 _UILayoutSpacer.bottom >= Subview2.bottom UISV-spanning-fit: _UILayoutSpacer.height == 0 priority:51 UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top _UILayoutSpacer.bottom == StackView.bottom UISV-ambiguity-suppression: Subview0.height == 0 priority:25 Subview1.height == 0 priority:25 Subview2.height == 0 priority:25 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
That’s a lot of constraints compared to the previous configurations! Let’s figure out what’s going on here.
The stack view added a new auxiliary _UILayoutSpacer
view to which it pins all of the arranged views with UISV-spanning-boundary constraints. Notice that all .bottom
constraints use .greaterThanOrEqual
relation.
Spacer’s height is bounded by UISV-spanning-fit constraint to disambiguate its height. The height of the arranged views also gets disambiguated automatically by stack view for you (UISV-ambiguity-suppression constraints).
Interestingly vertical UISV-canvas-connection constraints pin different views to the canvas this time. The first arranged view gets pinned to the top, while the spacer (_UILayoutSpacer
) gets pinned to the bottom.
Now why is that so complicated? Why is layout spacer necessary and is it necessary at all? Couldn’t you just pin all the arranged views to the stack view itself? I think that it might be excessive, but I might be missing something. If you have an idea why it’s implemented this way please share it in the comments.
.center #
A layout where the stack view aligns the center of its arranged views with its center along its axis.
UISV-alignment: Subview0.centerY == Subview1.centerY Subview0.centerY == Subview2.centerY UISV-spanning-boundary: _UILayoutSpacer.bottom >= Subview0.bottom _UILayoutSpacer.bottom >= Subview1.bottom _UILayoutSpacer.bottom >= Subview2.bottom _UILayoutSpacer.top <= Subview0.top _UILayoutSpacer.top <= Subview1.top _UILayoutSpacer.top <= Subview2.top UISV-spanning-fit: _UILayoutSpacer.height == 0 priority:51 UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == _UILayoutSpacer.top _UILayoutSpacer.bottom == StackView.bottom StackView.centerY == Subview0.centerY UISV-ambiguity-suppression: Subview0.height == 0 priority:25 Subview1.height == 0 priority:25 Subview2.height == 0 priority:25 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
This and the following alignment (.trailing) is very similar to a leading alignment. This particular configuration has an extra UISV-canvas-connection constraint that pins the first arranged view to the .centerY
of the stack view, and has slightly different UISV-spanning-boundary and UISV-alignment. But the idea is the same.
.trailing #
A layout for vertical stacks where the stack view aligns the trailing edge of its arranged views along its trailing edge.
UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom UISV-spanning-boundary: _UILayoutSpacer.top <= Subview0.top _UILayoutSpacer.top <= Subview1.top _UILayoutSpacer.top <= Subview2.top _UILayoutSpacer.bottom == Subview0.bottom priority:999.5 _UILayoutSpacer.bottom == Subview1.bottom priority:999.5 _UILayoutSpacer.bottom == Subview2.bottom priority:999.5 UISV-spanning-fit: _UILayoutSpacer.height == 0 priority:51 UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == _UILayoutSpacer.top Subview0.bottom == StackView.bottom UISV-ambiguity-suppression: Subview0.height == 0 priority:25 Subview1.height == 0 priority:25 Subview2.height == 0 priority:25 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
.firstBaseline #
A layout where the stack view aligns its arranged views based on their first baseline. This alignment is only valid for horizontal stacks.
UISV-alignment: Subview0.firstBaseline == Subview1.firstBaseline Subview0.firstBaseline == Subview2.firstBaseline UISV-spanning-boundary: _UILayoutSpacer.top <= Subview0.top _UILayoutSpacer.bottom >= Subview0.bottom _UILayoutSpacer.top <= Subview1.top _UILayoutSpacer.bottom >= Subview1.bottom _UILayoutSpacer.top <= Subview2.top _UILayoutSpacer.bottom >= Subview2.bottom UISV-spanning-fit: _UILayoutSpacer.height == 0 priority:51 UISV-canvas-fit: StackView.height == 0 priority:49 UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == _UILayoutSpacer.top _UILayoutSpacer.bottom == StackView.bottom UISV-text-width-disambiguation: Subview0.width == 0.333333*StackView.width priority:760 Subview1.width == 0.333333*StackView.width priority:760 Subview2.width == 0.333333*StackView.width priority:760 UISV-ambiguity-suppression: Subview0.height == 0 priority:25 Subview1.height == 0 priority:25 Subview2.height == 0 priority:25 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
The .firstBaseline
and the .lastBaseline
alignments only make sense for views like UILabel
which has some content with actual baselines (like text).
The set of constraints is similar to the previous scenario: it uses an auxiliary spacer, connects the spacer to the stack view, etc. But there is one discrepancy – UISV-text-width-disambiguation constraints. Without these constraints, the layout would be ambiguous – the layout system won’t be able to decide which text container to prioritize. I would say that this is a user error, and it looks like Apple decided to automatically correct it to produce a layout that’s not completely broken. But there is no trace of this behavior neither in the UIStackView documentation nor in headers, and because of that, I decided not to implement these constraints in Arranged.
.lastBaseline #
A layout where the stack view aligns its arranged views based on their last baseline. This alignment is only valid for horizontal stacks.
UISV-alignment: Subview0.lastBaseline == Subview1.lastBaseline Subview0.lastBaseline == Subview2.lastBaseline UISV-spanning-boundary: _UILayoutSpacer.top <= Subview0.top _UILayoutSpacer.bottom >= Subview0.bottom _UILayoutSpacer.top <= Subview1.top _UILayoutSpacer.bottom >= Subview1.bottom _UILayoutSpacer.top <= Subview2.top _UILayoutSpacer.bottom >= Subview2.bottom UISV-spanning-fit: _UILayoutSpacer.height == 0 priority:51 UISV-canvas-fit: StackView.height == 0 priority:49 UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == _UILayoutSpacer.top V:[Subview0]-(>=0)-| UISV-text-width-disambiguation: Subview0.width == 0.333333*StackView.width priority:760 Subview1.width == 0.333333*StackView.width priority:760 Subview2.width == 0.333333*StackView.width priority:760 UISV-ambiguity-suppression: Subview0.height == 0 priority:25 Subview1.height == 0 priority:25 Subview2.height == 0 priority:25 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
Misc #
Hiding Subviews #
The stack view automatically updates its layout whenever views are added, removed or inserted into the arrangedSubviews array, or whenever one of the arranged views’s isHidden property changes.
UISV-hiding: Subview0.width == 0 UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(10)-[Subview2] UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: StackView.leading == Subview0.leading Subview2.trailing == StackView.trailing StackView.top == Subview0.top Subview0.bottom == StackView.bottom
This one seems easy at first but is actually quite tricky. Hiding subviews is quite simple, UIStackView
just adds a single UISV-hiding constraint and recalculates the spacings. What’s more complicated is how UIStackView
reacts to isHidden
changes and how it perform animations.
The UIStackView
promises to do everything automagically for you:
// Animates removing the first item in the stack.
UIView.animateWithDuration(0.25) { () -> Void in
let firstView = stackView.arrangedSubviews[0]
firstView.hidden = true
}
What happens here is UIStackView
observes isHidden
property of the arranged views, cancels its effect, updates constraints, and automatically calls layoutIfNeeded()
at the end of the animation block. I’m a little concerned about what kind of trickery with private APIs is used by UIStackView
to make this work. I made a couple of attempts to make this work but ultimately decided that this behavior was both confusing and impractical to implement. I went with a simple fully controlled API:
UIView.animateWithDuration(0.33) {
stackView.setArrangedView(view, hidden: true)
stackView.layoutIfNeeded()
}
This way some of the responsibilities get delegated to the user of the library, but all the complexities of working with isHidden
are gone.
Margins Relative Layout #
A Boolean value that determines whether the stack view lays out its arranged views relative to its layout margins. If true, the stack view will layout its arranged views relative to its layout margins. If false, it lays out the arranged views relative to its bounds.
UISV-alignment: Subview0.bottom == Subview1.bottom Subview0.bottom == Subview2.bottom Subview0.top == Subview1.top Subview0.top == Subview2.top UISV-canvas-connection: UIViewLayoutMarginsGuide.leading == Subview0.leading UIViewLayoutMarginsGuide.trailing == Subview2.trailing UIViewLayoutMarginsGuide.top == Subview0.top UIViewLayoutMarginsGuide.bottom == Subview0.bottom UIView-margin-guide-constraint: V:[UIViewLayoutMarginsGuide]-(8)-| H:|-(8)-[UIViewLayoutMarginsGuide] H:[UIViewLayoutMarginsGuide]-(8)-| |-(8)-[UIViewLayoutMarginsGuide] UISV-spacing: H:[Subview0]-(0)-[Subview1] H:[Subview1]-(0)-[Subview2]
UISV-canvas-connection constraints now pin subviews to UIViewLayoutMarginsGuide
instead of bounds.
Baseline Relative Layout #
A Boolean value that determines whether the vertical spacing between views is measured from their baselines. If YES, the vertical space between views are measured from the last baseline of a text-based view, to the first baseline of the view below it. Top and bottom views are also positioned so that their closest baseline is the specified distance away from the stack view’s edge. This property is only used by vertical stack views.
UISV-spacing: Subview1.firstBaseline == Subview0.lastBaseline + 20 Subview2.firstBaseline == Subview1.lastBaseline + 20 UISV-alignment: Subview0.leading == Subview1.leading Subview0.leading == Subview2.leading Subview0.trailing == Subview1.trailing Subview0.trailing == Subview2.trailing UISV-canvas-connection: StackView.top == Subview0.top Subview2.bottom == StackView.bottom StackView.leading == Subview0.leading Subview0.trailing == StackView.trailing
The stack modifies UISV-spacing constraints by using .firstBaseline
and .lastBaseline
layout attributes.
Single Subview, Alignment.Center #
UISV-canvas-connection: StackView.leading == Subview0.leading H:[Subview0]-(0)-| StackView.top <= Subview0.top V:[Subview0]-(>=0)-| StackView.centerY == Subview0.centerY UISV-canvas-fit: StackView.height == 0 priority:49 UISV-ambiguity-suppression: Subview0.height == 0 priority:25
Compared to center alignment with multiple subviews, this configuration optimizes constraints by removing auxiliary spacer (_UILayoutSpacer
) which is not necessary when there is just a single arranged view.
Implementation #
If you’d like to check out the actual code please take a look at Arranged sources. The entire implementation takes about 500 lines of code!
Performance #
UIStackView
has a bunch of optimizations under the hood. One thing that it does for certain is updating only the constraints that need to be changed when some of the stack view options change (like, say, distribution). While this feature might be welcomed when the layout changes dynamically, I decided to avoid those optimizations to keep code as simple and reliable as possible. Even UIStackView
itself has a number of scenarios in which it fails to update all of the constraints when configuration changes.
Testing #
Testing Arranged was relatively simple. All I had to do was to test that Arranged.StackView
creates a set of constraints equivalent to the reference (UIStackView
) in all of those cases.