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:
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)
}
}