18

I'm experiencing problems with layout of arranged subviews in UIStackView and was wondering if someone could help me understand what's going on.

So I have UIStackView with some spacing (for example 1, but this does not matter) and .fillProportionally distribution. I'm adding arranged subviews with only intrinsicContentSize of 1x1 (could be anything, just square views) and I need them to be stretched proportionally within stackView.

The problem is that if I add views without actual frame, only with intrinsic sizes, then I get this wrong layout

wrong layout

Otherwise, if I add views with frames of the same size, everything works as expected,

correct layout

but I really prefer not to set view's frame at all.

I'm pretty sure that this is all about hugging and compression resistance priority, but can't figure out what right answer is.

Here is an Playground example:

import UIKit
import PlaygroundSupport

class LView: UIView {

    // If comment this and leave only intrinsicContentSize - result is wrong
    convenience init() {
        self.init(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
    }

    // If comment this and leave only convenience init(), then everything works as expected
    public override var intrinsicContentSize: CGSize {
        return CGSize(width: 1, height: 1)
    }
}

let container = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
container.backgroundColor = UIColor.white

let sv = UIStackView()
container.addSubview(sv)


sv.leftAnchor.constraint(equalTo: container.leftAnchor).isActive = true
sv.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true
sv.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
sv.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
sv.translatesAutoresizingMaskIntoConstraints = false
sv.spacing = 1
sv.distribution = .fillProportionally

// Adding arranged subviews to stackView, 24 elements with intrinsic size 1x1
for i in 0..<24 {
    let a = LView()
    a.backgroundColor = (i%2 == 0 ? UIColor.red : UIColor.blue)
    sv.addArrangedSubview(a)
}
sv.layoutIfNeeded()

PlaygroundPage.current.liveView = container
Arthur Grishin
  • 338
  • 2
  • 10
  • If I run your exact code, but change `.fillProportionally` to `.fillEqually` I get your 2nd image... Is that what you're going for? (by the way, you don't need the `sv.layoutIfNeeded()` inside your for loop) – DonMag Sep 16 '17 at 15:45
  • .fillEqually is actually not exactly what I want, because I need to have control over elements width (in this particular horizontal example) and .fillEqually will give equal width columns. I mean that I do need to have some columns twice bigger then others, for example, and few of others should be three times bigger than those that are twice bigger. About sv.layoutIfNeeded(), you're right, my fault, I'll move it outside of loop in this example. – Arthur Grishin Sep 16 '17 at 16:51
  • If you add a width anchor to each view inside the loop, will it solve your problem? – Woof Sep 17 '17 at 10:11
  • @Woof, no, that does not solve it. First of all, all views already have width constraint added automatically when adding them to arrangedSubviews. Second, if I set width anchor by myself, then .fillProportionally stops working at all, views don't scale proportionally depending of stackView width, for example, cause they have width constraint, even if this constraint is lessOrEqual or greaterOrEqual. Third, why should I set width constraints by myself if each view already has it's own intrinsicContentSize, and according to Apple docs .fillProportionally relies on intrinsicContentSize. – Arthur Grishin Sep 17 '17 at 12:56
  • Sorry, but you said that you need to control width without setting the frame, and each column may have different width, that's why I answered that way. So confirm please, you are going to fill the stack by views that may have different width according to content proportionally, right? – Woof Sep 17 '17 at 13:27
  • That's what I'm about to do: I'm going to fill UIStackView with random amount of views with default intrinsicContentSize set to 1x1. Each view will have property weight, and changing that property will change intrinsicContentSize of stackView axis (width for .horizontal and height for .vertical), for example if view's weight is 2 and UIStackView.axis is .horizontal then view's intrinsicContentSize will become 2x1 (width: (1*weight), height: 1). So views inside UIStackView should scale proportionally according to it's weight. – Arthur Grishin Sep 17 '17 at 14:19
  • I removed lot's of unnecessary code to just demonstrate strange behaviour of UIStackView. According to Apple's documentation for .fillProportionally distribution "Views are resized proportionally based on their intrinsicContentSize along the stack view’s axis.". So, in playground demo they all have same intrinsicContentSize, but layout is a bit unexpected. Last view in a stack is always lot bigger then others. – Arthur Grishin Sep 17 '17 at 14:24
  • 1
    After a bit of testing... it seems `.fillProportionally` ends up with weird results when `.spacing` is non-zero (change to `sv.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. – DonMag Sep 17 '17 at 16:23
  • Alright, so there, as I suspected, might be a workaround. I can define `spacing = 0` and then wrap every `arrangedSubview` into a wrapper-view, define a width constraint of arrangedSubview equals to `wrapperView.width - someSpacing`. But honestly, this does not seem the best way to solve this "quirk". – Arthur Grishin Sep 18 '17 at 10:00
  • Using `.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 of `arrangedSubviews` you need to set a `width` constraint to each `arrangedSubview` 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 call `layoutIfNeeded()` – Ayazmon Sep 19 '17 at 14:04
  • 1
    But `width` constraints are meant to be set automatically by `UIStackView`, aren't they? When your add view as `arrangedSubview`, `width` constraint is calculated based on `intrisicContentSize` of added view and count and sizes of existing views in `UIStackView` and applied to view. Why do I need to add `width` constraint manually one more time? Btw, if I check View Debugging, last element that has biggest width, has `width` constraint disabled. And this is quite odd imo. – Arthur Grishin Sep 20 '17 at 08:09
  • comment sv.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true?? does it make sense to have right constraint? – HMHero Sep 22 '17 at 16:45
  • I have observed similar odd behavior with fillProportionally. I have a setup where I want to control the proportions of a group of views dynamically, and play with the intrinsic content size to allow it, however depending on the contents of a given subview, the stackview treats it differently and resize it, even though the intrinsic content size does not change. Have you found a suitable work around for this? – Rafael Nobre May 11 '22 at 15:02

2 Answers2

44

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 proportionalConstraints 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 proportionalConstraints. 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:

How the stack view should compute the multiplier


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 UIViews with a required fixed-width constraint as spacings between the views. (Normally, UILayoutGuides 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.

Mischa
  • 15,816
  • 8
  • 59
  • 117
2

As of Xcode 9.2 at least, the playground provided works as intended provided the initializer and the intrinsic content size are both commented out. In that case, the proportional filling works as expected, even with spacing > 0

This is an example with spacing = 5

enter image description here

That seems to make sense because the arranged subviews have no intrinsic content size and the StackView determines their widths to proportionally fill the designated axis.

If only the initializer is enabled (and not the intrinsic content size override), then I get this, which doesn't match the comment in the playground, so I guess this behaviour must have changed since the question was posted:

enter image description here

I don't understand that behaviour, because it would seem to me that setting the frame manually should be ignored when using Auto Layout.

If only the intrinsic content size override is enabled (and not the initializer) then I get the problematic image that originated this post (here with spacing = 5): enter image description here

Essentially, the design now is conflicting and can't be realized, because views want to be 1 point wide, due to specified intrinsic content size. The total space here should be

24 views * 1 point/view + 23 spaces * 5 points/space = 139 < 300 = sv.bounds.width

with the last arranged view's constraint broken due to lowest priority as pointed out by Mischa.

Upon pixel-per-pixel inspection the first 23 views above are wider than 1 pixel though, 2 or 3 pixels actually, except for the last one, thus the math doesn't quite match, I don't know why, possibly rounding up of decimal numbers?

For reference, this is what it looks like in that case with intrinsic content size of width 5, still failing to satisfy the constraints.

enter image description here

atineoSE
  • 3,597
  • 4
  • 27
  • 31