Reputation: 3538
I'm using NSLayoutConstraint to constrain a view. I want its height to take up 50% of the screen by default, but if there is not enough room for the other components (eg iphone in landscape), the view can shrink to as little as 10% of the height.
I'm trying:
let y1 = NSLayoutConstraint(item: button, attribute: .top, relatedBy: .equal,
toItem: self.view, attribute: .top, multiplier: 1, constant: 0)
let y2 = NSLayoutConstraint(item: button, attribute: .height, relatedBy: .lessThanOrEqual,
toItem: self.view, attribute: .height, multiplier: 0.5, constant: 0)
let y3 = NSLayoutConstraint(item: button, attribute: .height, relatedBy: .greaterThanOrEqual,
toItem: self.view, attribute: .height, multiplier: 0.1, constant: 0)
Unfortunately this renders as only 10% of the height of the screen.
I'm confused by two things:
When I set ambiguous constraints like this, that basically say "between 10% and 50%", how does it decide how much height to give it? Does it default to the minimum amount of space?
I thought that constraints had to only have one solution. Why don't I get an ambiguity error, since any heights from 10% to 50% would be valid solutions here?
Finally, how do I get what I want, a 50% view that can shrink if needed?
Many thanks!
Upvotes: 0
Views: 598
Reputation: 77486
You can do this by changing the Priority
of the 50% height constraint.
We'll tell auto-layout the button must be at least 10% of the height of the view.
And, we'll tell auto-layout we want the button to be 50% of the height of the view, but:
.priority = .defaultHigh
which says "you can break this constraint if needed."
So...
// constrain button Top to view Top
let btnTop = NSLayoutConstraint(item: button, attribute: .top, relatedBy: .equal,
toItem: self.view, attribute: .top, multiplier: 1, constant: 0)
// button Height Greater Than Or Equal To 10%
let percent10 = NSLayoutConstraint(item: button, attribute: .height, relatedBy: .greaterThanOrEqual,
toItem: self.view, attribute: .height, multiplier: 0.1, constant: 0)
// button Height Equal To 50%
let percent50 = NSLayoutConstraint(item: button, attribute: .height, relatedBy: .equal,
toItem: self.view, attribute: .height, multiplier: 0.5, constant: 0)
// let auto-layout break the 50% height constraint if necessary
percent50.priority = .defaultHigh
[btnTop, percent10, percent50].forEach {
$0.isActive = true
}
Or, with more modern syntax...
let btnTop = button.topAnchor.constraint(equalTo: view.topAnchor)
let percent10 = button.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor, multiplier: 0.10)
let percent50 = button.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.50)
percent50.priority = .defaultHigh
NSLayoutConstraint.activate([btnTop, percent10, percent50])
Now, whatever other UI elements you have that will reduce the available space, auto-layout will set the button's height to "as close to 50% as possible, but always at least 10%"
Here's a complete example to demonstrate. I'm using two labels (blue on top as the "button" and red on the bottom). Tapping will increase the height of the red label, until it starts to "push up the bottom" or "compress" the blue label:
class ExampleViewController: UIViewController {
let blueLabel = UILabel()
let redLabel = UILabel()
var viewSafeAreaHeight: CGFloat = 0
var adjustableLabelHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
[blueLabel, redLabel].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
v.textAlignment = .center
v.textColor = .white
v.numberOfLines = 0
}
blueLabel.backgroundColor = .blue
redLabel.backgroundColor = .red
view.addSubview(blueLabel)
view.addSubview(redLabel)
// blueLabel should be 50% of the height if possible
// otherwise, let it shrink to minimum of 10%
// so, we'll constrain redLabel to the bottom of the view
// and give it a Height constraint that we can change
// so it can "compress" blueLabel
// we'll constrain the bottom of blueLabel to stay above the top of redLabel
// let's respect the safe-area
let safeArea = view.safeAreaLayoutGuide
// start by horizontally centering both elements,
// and 75% of the width of the view
blueLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor).isActive = true
redLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor).isActive = true
blueLabel.widthAnchor.constraint(equalTo: safeArea.widthAnchor, multiplier: 0.75).isActive = true
redLabel.widthAnchor.constraint(equalTo: safeArea.widthAnchor, multiplier: 0.75).isActive = true
// now, let's constrain redLabel to the bottom
redLabel.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor).isActive = true
// tell the Bottom of blueLabel to stay Above the top of redLabel
blueLabel.bottomAnchor.constraint(lessThanOrEqualTo: redLabel.topAnchor, constant: 0.0).isActive = true
// next, constrain the top of blueLabel to the top
let blueLabelTop = blueLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 0.0)
// blueLabel height must be At Least 10% of the view
let blue10 = blueLabel.heightAnchor.constraint(greaterThanOrEqualTo: safeArea.heightAnchor, multiplier: 0.10)
// blueLabel should be 50% if possible -- so we'll set the
// Priority on that constraint to less than Required
let blue50 = blueLabel.heightAnchor.constraint(equalTo: safeArea.heightAnchor, multiplier: 0.50)
blue50.priority = .defaultHigh
// start redLabel Height at 100-pts
adjustableLabelHeightConstraint = redLabel.heightAnchor.constraint(equalToConstant: 100.0)
// we'll be increasing the Height constant past the available area,
// so we also need to change its Priority so we don't get
// auto-layout conflict errors
// and, we need to set it GREATER THAN blueLabel's height priority
adjustableLabelHeightConstraint.priority = UILayoutPriority(rawValue: blue50.priority.rawValue + 1)
// activate those constraints
NSLayoutConstraint.activate([blueLabelTop, blue10, blue50, adjustableLabelHeightConstraint])
// add a tap gesture recognizer so we can increas the height of the label
let t = UITapGestureRecognizer(target: self, action: #selector(self.gotTap(_:)))
view.addGestureRecognizer(t)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
viewSafeAreaHeight = view.frame.height - (view.safeAreaInsets.top + view.safeAreaInsets.bottom)
updateLabelText()
}
@objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
adjustableLabelHeightConstraint.constant += 50
updateLabelText()
}
func updateLabelText() -> Void {
let blueHeight = blueLabel.frame.height
let redHeight = redLabel.frame.height
let redConstant = adjustableLabelHeightConstraint.constant
let percentFormatter = NumberFormatter()
percentFormatter.numberStyle = .percent
percentFormatter.minimumFractionDigits = 2
percentFormatter.maximumFractionDigits = 2
guard let bluePct = percentFormatter.string(for: blueHeight / viewSafeAreaHeight) else { return }
var s = "SafeArea Height: \(viewSafeAreaHeight)"
s += "\n"
s += "Blue Height: \(blueHeight)"
s += "\n"
s += "\(blueHeight) / \(viewSafeAreaHeight) = \(bluePct)"
blueLabel.text = s
s = "Tap to increase..."
s += "\n"
s += "Red Height Constant: \(redConstant)"
s += "\n"
s += "Red Actual Height: \(redHeight)"
redLabel.text = s
}
}
Upvotes: 3