Search code examples
iosswiftautolayout

With swift ios programming constraints, how to specify a view that defaults to 50% of the height but can shrink if needed?


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:

  1. 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?

  2. 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!


Solution

  • 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
        }
    }