Why does a UIStackView with a single view, fill Proportionally, and Layout Margins causes ambiguous constraint error?
Asked Answered
O

2

5

The following code is an attempt to add a UIStackView to a view controller, pinned on all edges with a little margin, and add a label to it.

I want the StackView to use .fillProportionally as its distribution mode ready for when I add more views into it later on.

It seems that for a single arranged subview whenever the distribution mode is .fillProportionally and layout margins are used then I get an ambiguous constrain error (below). What is the cause of this error?

override func viewDidLoad() {
    super.viewDidLoad()

    let label = UILabel(frame: .zero)
    label.text = "ABC"

    let stack = UIStackView(arrangedSubviews: [label])
    stack.translatesAutoresizingMaskIntoConstraints = false
    stack.distribution = .fillProportionally
    stack.isLayoutMarginsRelativeArrangement = true
    stack.layoutMargins = .init(top: 10, left: 10, bottom: 10, right: 10)
    view.addSubview(stack)

    NSLayoutConstraint.activate([
        stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        stack.widthAnchor.constraint(equalTo: view.widthAnchor),
        stack.heightAnchor.constraint(equalTo: view.heightAnchor),
    ])
}

}

Abiguous constraint error (WTFAutoLayout):

(
    "<NSLayoutConstraint:0x600001a432f0 'UISV-canvas-connection' UILayoutGuide:0x6000000f3100'UIViewLayoutMarginsGuide'.leading == UILabel:0x7ff470913280'ABC'.leading   (active)>",
    "<NSLayoutConstraint:0x600001a423f0 'UISV-canvas-connection' UILayoutGuide:0x6000000f3100'UIViewLayoutMarginsGuide'.trailing == UILabel:0x7ff470913280'ABC'.trailing   (active)>",
    "<NSLayoutConstraint:0x600001a425d0 'UISV-fill-proportionally' UILabel:0x7ff470913280'ABC'.width == UIStackView:0x7ff46d510030.width   (active)>",
    "<NSLayoutConstraint:0x600001a77f70 'UIView-leftMargin-guide-constraint' H:|-(10)-[UILayoutGuide:0x6000000f3100'UIViewLayoutMarginsGuide'](LTR)   (active, names: '|':UIStackView:0x7ff46d510030 )>",
    "<NSLayoutConstraint:0x600001a42940 'UIView-rightMargin-guide-constraint' H:[UILayoutGuide:0x6000000f3100'UIViewLayoutMarginsGuide']-(10)-|(LTR)   (active, names: '|':UIStackView:0x7ff46d510030 )>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x600001a423f0 'UISV-canvas-connection' UILayoutGuide:0x6000000f3100'UIViewLayoutMarginsGuide'.trailing == UILabel:0x7ff470913280'ABC'.trailing   (active)>
Optimism answered 8/5, 2020 at 13:1 Comment(1)
Did you ever find an approach to this? Whilst I understand DonMag's excellent explanation below of why this is happening, I can't begin to think of how to fix it or workaround it. – Gamma
P
17

Editing with clarification because my original answer was not entirely correct...

First, the .fillProportionally distribution property of a UIStackView is very often misunderstood.

Second, we get some oddities when a stack view's distribution is .fillProportionally and the stack view's .spacing is not 0, or when the stack view also has .layoutMargins set.

The problem you're hitting is the way auto-layout calculates the proportional sizing.

Based on experimentation, auto-layout calculates the proportional widths of the views and then applies the layout margins, subtracting from the width of the last view to accommodate the space.

This can be easily demonstrated as follows:

enter image description here

There are 6 horizontal stack views, each set to 200-pts wide, Distribution set to .fillProportionally, and filled with either one or two views. The Red views have an intrinsic width of 25, the Green views 75.

The first stack view, with a single view and no layout margins, fills the width as expected... the red view takes up 100% of the space.

The second stack view, with two views and no layout margins, also fills as expected... the red view is 50-pts wide (25%) and the green view is 150-pts wide (75%).

The third stack view, though, starts showing the problem. The single view is give a proportional width of 100%, or 200-pts... but then the layout margins are applied. This shifts the view 10-pts from the left, but because auto-layout doesn't subtract space from the first subview, it actually extends 10-pts past the edge of the stack view (so the red view is still 200-pts wide).

The fourth stack view looks like it's doing what we want... proportional fill with 10-pts margin on each side... the Red view is 50-pts wide (25% of 200) but the Green view is only 130-pts wide. So auto-layout gave the two views 50-pts (25%) and 150-pts (75%) but then it applied the margins and took the 20-pts away from the Green view.

Using layout margins of left: 100 right: 0 or left: 0 right: 100 for the bottom two stack views makes it much more obvious. Again, for each of those, Red gets 50-pts (25%) and Green gets 150-pts (75%), but then the margin of 100-pts gets stripped from Green.


So, to answer the original question about why we get ambiguous constraints when we have a single arranged subview and layout margins, look at stack view 3. Auto-layout could not manage to give Red 100% of the space and apply margins, so it throws the layout error.


Here's the code to run the above example. If you comment-out the third stack view, you won't get the error:

class ProportionalStackExampleViewController: UIViewController {

    let outerStackView: UIStackView = {
        let v = UIStackView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.axis = .vertical
        v.spacing = 8
        return v
    }()

    let outerStackFrame: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.layer.borderWidth = 0.5
        v.layer.borderColor = UIColor.blue.cgColor
        return v
    }()

    let infoLabel: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.font = UIFont.systemFont(ofSize: 12.0, weight: .light)
        v.numberOfLines = 0
        v.textAlignment = .center
        v.text = "Red views have intrinsic width of 25\nGreen views have intrinsic width of 75\nAll horizontal stack views are 200-pts wide\nTap any view to see its width"
        return v
    }()

    let sizeLabel: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.text = "(width)"
        return v
    }()

    let myGreen = UIColor(red: 0, green: 0.75, blue: 0, alpha: 1.0)

    override func viewDidLoad() {
        super.viewDidLoad()

        for _ in 1...6 {
            let lbl = UILabel()
            lbl.font = UIFont.systemFont(ofSize: 12.0, weight: .light)
            lbl.numberOfLines = 0
            lbl.textAlignment = .center
            outerStackView.addArrangedSubview(lbl)
            let sv = UIStackView()
            sv.translatesAutoresizingMaskIntoConstraints = false
            sv.axis = .horizontal
            sv.distribution = .fillProportionally
            sv.spacing = 0
            outerStackView.addArrangedSubview(sv)
        }

        view.addSubview(infoLabel)
        view.addSubview(sizeLabel)
        view.addSubview(outerStackFrame)
        view.addSubview(outerStackView)

        let g = view.safeAreaLayoutGuide

        NSLayoutConstraint.activate([

            infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),

            sizeLabel.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 20.0),
            sizeLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),

            outerStackView.topAnchor.constraint(equalTo: sizeLabel.bottomAnchor, constant: 20.0),
            outerStackView.widthAnchor.constraint(equalToConstant: 200.0),
            outerStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),

            outerStackFrame.widthAnchor.constraint(equalTo: outerStackView.widthAnchor),
            outerStackFrame.heightAnchor.constraint(equalTo: outerStackView.heightAnchor),
            outerStackFrame.centerXAnchor.constraint(equalTo: outerStackView.centerXAnchor),
            outerStackFrame.centerYAnchor.constraint(equalTo: outerStackView.centerYAnchor),
        ])

        // StackView 1
        if let lbl = outerStackView.arrangedSubviews[0] as? UILabel,
            let sv = outerStackView.arrangedSubviews[1] as? UIStackView {

            lbl.text = "One view, no layoutMargins"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

        }

        // StackView 2
        if let lbl = outerStackView.arrangedSubviews[2] as? UILabel,
            let sv = outerStackView.arrangedSubviews[3] as? UIStackView {

            lbl.text = "Two views, no layoutMargins"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

            v = ProportionalView()
            v.w = 75.0
            v.backgroundColor = myGreen
            sv.addArrangedSubview(v)

            tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)
        }

        // comment out this block to see the auto-layout error goes away
        // StackView 3
        if let lbl = outerStackView.arrangedSubviews[4] as? UILabel,
            let sv = outerStackView.arrangedSubviews[5] as? UIStackView {

            lbl.text = "One view\nlayoutMargins left: 10 right: 10"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            sv.isLayoutMarginsRelativeArrangement = true
            sv.layoutMargins = .init(top: 0, left: 10, bottom: 0, right: 10)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

        }

        // StackView 4
        if let lbl = outerStackView.arrangedSubviews[6] as? UILabel,
            let sv = outerStackView.arrangedSubviews[7] as? UIStackView {

            lbl.text = "Two views\nlayoutMargins left: 10 right: 10"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

            v = ProportionalView()
            v.w = 75.0
            v.backgroundColor = myGreen
            sv.addArrangedSubview(v)

            sv.isLayoutMarginsRelativeArrangement = true
            sv.layoutMargins = .init(top: 0, left: 10, bottom: 0, right: 10)

            tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)
        }

        // StackView 5
        if let lbl = outerStackView.arrangedSubviews[8] as? UILabel,
            let sv = outerStackView.arrangedSubviews[9] as? UIStackView {

            lbl.text = "layoutMargins left: 100 right: 0"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

            v = ProportionalView()
            v.w = 75.0
            v.backgroundColor = myGreen
            sv.addArrangedSubview(v)

            sv.isLayoutMarginsRelativeArrangement = true
            sv.layoutMargins = .init(top: 0, left: 100, bottom: 0, right: 0)

            tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)
        }

        // StackView 6
        if let lbl = outerStackView.arrangedSubviews[10] as? UILabel,
            let sv = outerStackView.arrangedSubviews[11] as? UIStackView {

            lbl.text = "layoutMargins left: 0 right: 100"

            var v = ProportionalView()
            v.w = 25.0
            v.backgroundColor = .red
            sv.addArrangedSubview(v)

            var tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)

            v = ProportionalView()
            v.w = 75.0
            v.backgroundColor = myGreen
            sv.addArrangedSubview(v)

            sv.isLayoutMarginsRelativeArrangement = true
            sv.layoutMargins = .init(top: 0, left: 0, bottom: 0, right: 100)

            tg = UITapGestureRecognizer(target: self, action: #selector(showWidth(_:)))
            v.addGestureRecognizer(tg)
        }

    }

    @objc func showWidth(_ sender: UITapGestureRecognizer) -> Void {
        if let v = sender.view {
            sizeLabel.text = "Width: \(v.frame.width)"
            sizeLabel.textColor = v.backgroundColor
        }
    }

}

class ProportionalView: UIView {
    var w: CGFloat = 1.0

    override var intrinsicContentSize: CGSize {
        return CGSize(width: w, height: 40.0)
    }

}
Partain answered 8/5, 2020 at 21:21 Comment(7)
πŸ‘ What an excellent answer. This is exactly the insight I was missing, thank you! – Optimism
Great explanation. I've been trying to understand this for days. I have a similar scenario although it's with inner stack views that have multiple subviews but when embedded inside a scrollView and then added to a viewController as a childVC, the proportional constraints start breaking. Still have no idea how to fix but at least I understand why it's breaking – Gamma
@Gamma - when you say "the proportional constraints start breaking" ... do you mean subview constraints proportional to each other? Or are you trying to use Fill Proportionally on stack views? – Partain
@Partain - I've tried laying it out in different ways. The best seems to work but gives me UISV-spacing constraint conflicts. Happy to share code, raise seperate question or DM – Gamma
@Gamma - either post it as a new question or post the code somewhere (GitHub, pastebin, etc) and I'll take a look. – Partain
@Partain - thanks. Posted here: #66647371 – Gamma
@Partain - not sure if you saw but would really appreciate some help. Thanks in advance. – Gamma
S
1

First of all, set translatesAutoresizingMaskIntoConstraints of label to false.

label.translatesAutoresizingMaskIntoConstraints = false

Since you want to set all the layoutMargins to 10, the width and height of stack can not be equal to view's width and height.

You need to accommodate that difference of 20 (both sides) in width and height.

So the constraints should be,

NSLayoutConstraint.activate([
    stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    stack.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 1, constant: -20),
    stack.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 1, constant: -20),
])

Also, the margin spacing is included within the constraints, there is no need to write the below code. So remove it.

stack.isLayoutMarginsRelativeArrangement = true
stack.layoutMargins = .init(top: 10, left: 10, bottom: 10, right: 10)
Sialkot answered 8/5, 2020 at 13:21 Comment(1)
Thanks for your answer! I'm particularly looking for a solution to the implications of Layout Margins and fillProportional rather than a way of working around it. For example if I were trying to achieve the same layout by subclassing UIStackView instead of embedding it then I wouldn't want to touch the width and height constraint's constants – Optimism

© 2022 - 2024 β€” McMap. All rights reserved.