This is obviously a bug in the implementation of UIStackView
(i.e. a system bug).
DonMag already gave a hint pointing in the right direction in his comment:
When you set the stack view's spacing
to 0, everything works as expected. But when you set it to any other value, the layout breaks.
Here's the explanation why:
ℹ️ For the sake of simplicity I will assume that the stack view has
- a horizontal axis and
- 10 arranged subviews
With the .fillProportionally
distribution UIStackView
creates system-constraints as follows:
For each arranged subview, it adds an equal width constraint (UISV-fill-proportionally
) that relates to the stack view itself with a multiplier:
arrangedSubview[i].width = multiplier[i] * stackView.width
If you have n arranged subviews in the stack view, you get n of these constraints. Let's call them proportionalConstraint[i]
(where i
denotes the position of the respective view in the arrangedSubviews
array).
These constraints are not required (i.e. their priority is not 1000). Instead, the constraint for the first element in the arrangedSubviews
array is assigned a priority of 999, the second is assigned a priority of 998 etc.:
proportionalConstraint[0].priority = 999
proportionalConstraint[1].priority = 998
proportionalConstraint[2].priority = 997
proportionalConstraint[3].priority = 996
...
proportionalConstraint[n–1].priority = 1000 – n
This means that required constraints will always win over these proportional constraints!
For connecting the arranged subviews (possibly with a spacing) the system also creates n–1 constraints called UISV-spacing
:
arrangedSubview[i].trailing + spacing = arrangedSubview[i+1].leading
These constraints are required (i.e. priority = 1000).
(The system will also create some other constraints (e.g. for the vertical axis and for pinning the first and last arranged subview to the edge of the stack view) but I won't go into detail here because they're not relevant for understanding what's going wrong.)
Apple's documentation on the .fillProportionally
distribution states:
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.
So according to this the multiplier
for the proportionalConstraint
s should be computed as follows for spacing = 0
:
totalIntrinsicWidth
= ∑i intrinsicWidth[i]
multiplier[i]
= intrinsicWidth[i]
/ totalIntrinsicWidth
If our 10 arranged subviews all have the same intrinsic width, this works as expected:
multiplier[i] = 0.1
for all proportionalConstraint
s. However, as soon as we change the spacing to a non-zero value, the calculation of the multiplier
becomes a lot more complex because the widths of the spacings have to be taken into account. I've done the maths and the formula for multiplier[i]
is:
Example:
For a stack view configured as follows:
- stackView.width = 400
- stackView.spacing = 2
the above equation would yield:
multiplier[i] = 0.0955
You can prove this correct by adding it up:
(10 * width) + (9 * spacing)
= (10 * multiplier * stackViewWidth) + (9 * spacing)
= (10 * 0.0955 * 400) + (9 * 2)
= (0.955 * 400) + 18
= 382 + 18
= 400
= stackViewWidth
However, the system assigns a different value:
multiplier[i] = 0.0917431
which adds up to a total width of
(10 * width) + (9 * spacing)
= (10 * 0.0917431 * 400) + (9 * 2)
= 384,97
< stackViewWidth
Obviously, this value is wrong.
As a consequence the system has to break a constraint. And of course, it breaks the constraint with the lowest priority which is the proportionalConstraint
of the last arranged subview item.
That's the reason why the last arranged subview in your screenshot is stretched.
If you try out different spacings and stack view widths you'll end up with all sorts of weird-looking layouts. But they all have one thing in common:
The spacings always take precedence. (If you set the spacing to a greater value like 30 or 40 you'll only see the first two or three arranged subviews because the rest of the space is fully occupied by the required spacings.)
To sum things up:
The .fillProportionally
distribution only works properly with spacing = 0
.
For other spacings the system creates constraints with an incorrect multiplier.
This breaks the layout as
- either one of the arranged subviews (the last) has to be stretched if the multiplier is smaller than it should be
- multiple arranged subviews have to be compressed if the multiplier is greater than it should be.
The only way out of this is to "misuse" plain UIView
s with a required fixed-width constraint as spacings between the views. (Normally, UILayoutGuide
s were introduced for this purpose but you cannot even use those either because you cannot add layout guides to a stack view.)
I'm afraid that due to this bug, there is no clean solution to do this.
.fillProportionally
to.fillEqually
I get your 2nd image... Is that what you're going for? (by the way, you don't need thesv.layoutIfNeeded()
inside your for loop) – Ewens.fillProportionally
ends up with weird results when.spacing
is non-zero (change tosv.spacing = 0
and you'll get your second image, just without space between views). I don't know if it would be considered a "bug" - or just a "quirk". It's almost as if auto-layout is applying proportional sizing to the spaces --- but then rendering them with absolute values. – Ewensspacing = 0
and then wrap everyarrangedSubview
into a wrapper-view, define a width constraint of arrangedSubview equals towrapperView.width - someSpacing
. But honestly, this does not seem the best way to solve this "quirk". – Romilly.fillProportionally
is the answer to your question. But you need to do some other adjustments to your code. Since you don't actually want the result of.fillEqually
and adjust the sizes ofarrangedSubviews
you need to set awidth
constraint to eacharrangedSubview
then you should change that constraint to your liking. For example in your desired output image every constraint value can be equal to 10 but when you want first 2 columns to be bigger than the others you can increase those views'width
constraint to some higher value and calllayoutIfNeeded()
– Callahanwidth
constraints are meant to be set automatically byUIStackView
, aren't they? When your add view asarrangedSubview
,width
constraint is calculated based onintrisicContentSize
of added view and count and sizes of existing views inUIStackView
and applied to view. Why do I need to addwidth
constraint manually one more time? Btw, if I check View Debugging, last element that has biggest width, haswidth
constraint disabled. And this is quite odd imo. – Romilly