Search code examples
iosswiftautolayoutsnapkit

AutoLayout constraints to fit view inside rectangle, preserving a certain aspect ratio (programmatically)


I want to fit an image inside a rectangle that should have a specific aspect ratio. No matter what it is, it should find a form to fit inside the rectangle. I played around in storyboard and got this:

enter image description here

The ones with the dotted border have low priority (250). This works in the storyboard. However, I need to create these constraints programmatically, so I tried it like this (I'm using SnapKit, which simply provides better AutoLayout syntax. It should be self-explaining):

let topView = UIView()
topView.translatesAutoresizingMaskIntoConstraints = false
topView.backgroundColor = .gray
view.addSubview(topView)

topView.snp.makeConstraints { (make) in
        make.top.equalToSuperview()
        make.left.equalToSuperview()
        make.trailing.equalToSuperview()
        make.height.equalTo(250)
        }

// This view should have a specific aspect ratio and fit inside topView
let holderView = UIView()
holderView.translatesAutoresizingMaskIntoConstraints = false
holderView.backgroundColor = .red
topView.addSubview(holderView)

holderView.snp.makeConstraints { (make) in
        make.center.equalToSuperview() // If I remove this one, there's no auto-layout issue, but then it's offset
        make.edges.equalToSuperview().priority(250) // sets leading, trailing, top and bottom
        make.edges.greaterThanOrEqualToSuperview().priority(1000)
        make.width.equalTo(holderView.snp.height).multipliedBy(3/2)
        }

If you paste this into an empty ViewController and start it up, you get these issues:

2018-03-16 15:38:50.188867+0100 DemoProject[11298:850932] [LayoutConstraints] Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list is one you don't want. 
Try this: 
    (1) look at each constraint and try to figure out which you don't expect; 
    (2) find the code that added the unwanted constraint or constraints and fix it. 

"<SnapKit.LayoutConstraint:[email protected]#24 UIView:0x7fcd82d12440.left == UIView:0x7fcd82d12640.left>",
"<SnapKit.LayoutConstraint:[email protected]#25 UIView:0x7fcd82d12440.trailing == UIView:0x7fcd82d12640.trailing>",
"<SnapKit.LayoutConstraint:[email protected]#26 UIView:0x7fcd82d12440.height == 250.0>",
"<SnapKit.LayoutConstraint:[email protected]#35 UIView:0x7fcd8580dad0.centerX == UIView:0x7fcd82d12440.centerX>",
"<SnapKit.LayoutConstraint:[email protected]#35 UIView:0x7fcd8580dad0.centerY == UIView:0x7fcd82d12440.centerY>",
"<SnapKit.LayoutConstraint:[email protected]#37 UIView:0x7fcd8580dad0.top >= UIView:0x7fcd82d12440.top>",
"<SnapKit.LayoutConstraint:[email protected]#37 UIView:0x7fcd8580dad0.right >= UIView:0x7fcd82d12440.right>",
"<SnapKit.LayoutConstraint:[email protected]#38 UIView:0x7fcd8580dad0.width == UIView:0x7fcd8580dad0.height>",
"<NSLayoutConstraint:0x600000092cf0 'UIView-Encapsulated-Layout-Width' UIView:0x7fcd82d12640.width == 414   (active)>"


Will attempt to recover by breaking constraint <SnapKit.LayoutConstraint:[email protected]#38 UIView:0x7fcd8580dad0.width == UIView:0x7fcd8580dad0.height>

This doesn't show up, when I remove the centering constraint make.center.equalToSuperview(). But then, it's misplaced.

What is different between the storyboard and my code? I don't really understand this. I also tried this using the default swift syntax, the result was exactly the same. So I don't think it's a problem with SnapKit

Any ideas? Thank you guys for any help. Let me know if you need any more infos.

EDIT: I mixed something up. It's not about the image and its aspect ratio. It's just about a UIView that should maintain a specific aspect ratio while fitting inside a rectangle. The actual image will just be put into that holderView. Sorry


Solution

  • OK - here is one way to do it.

    Take the "native" size of your subview, calculate the "aspect fit" ratio - that is, the ratio that will fit the width or height to the superview, and scale the other dimension appropriately.

    Then, use centerXAnchor and centerYAnchor to position the subview, and widthAnchor and heightAnchor to size it.

    Note: if you're trying to place an image, calculate the aspect fit size from the image size, put the image in an image view, set the image view scale mode to fill, and finally apply the constraints to the image view.

    You should be able to run this example as-is. Just play around with the "native" size values at the top to see how it fits the subview into the superview.

    public class AspectFitViewController : UIViewController {
    
        // "native" size for the holderView
        let hViewWidth: CGFloat = 700.0
        let hViewHeight: CGFloat = 200.0
    
        let topView: UIView = {
            let v = UIView()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.backgroundColor = UIColor.blue
            return v
        }()
    
        let holderView: UIView = {
            let v = UIView()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.backgroundColor = UIColor.cyan
            return v
        }()
    
        public override func viewDidLoad() {
            super.viewDidLoad()
            view.bounds = CGRect(x: 0, y: 0, width: 400, height: 600)
            view.backgroundColor = .yellow
    
            // add topView
            view.addSubview(topView)
    
            // pin topView to leading / top / trailing
            topView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
            topView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
            topView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    
            // explicit height for topView
            topView.heightAnchor.constraint(equalToConstant: 250.0).isActive = true
    
            // add holderView to topView
            topView.addSubview(holderView)
    
            // center X and Y
            holderView.centerXAnchor.constraint(equalTo: topView.centerXAnchor, constant: 0.0).isActive = true
            holderView.centerYAnchor.constraint(equalTo: topView.centerYAnchor, constant: 0.0).isActive = true
    
            // holderView's width and height will be calculated in viewDidAppear
            // after topView has been laid-out by the auto-layout engine
    
        }
    
        public override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            let aspectWidth  = topView.bounds.size.width / hViewWidth
            let aspectHeight = topView.bounds.size.height / hViewHeight
    
            let aspectFit = min(aspectWidth, aspectHeight)
    
            let newWidth = hViewWidth * aspectFit
            let newHeight = hViewHeight * aspectFit
    
            holderView.widthAnchor.constraint(equalToConstant: newWidth).isActive = true
            holderView.heightAnchor.constraint(equalToConstant: newHeight).isActive = true
    
        }
    
    }
    

    Edit:

    After clarification... this can be accomplished by constraints only. The key is that "Priority 1000" top and leading constraints must be .greaterThanOrEqual to zero, and the bottom and trailing constraints must be .lessThanOrEqual to zero.

    public class AspectFitViewController : UIViewController {
    
        // "native" size for the holderView
        let hViewWidth: CGFloat = 700.0
        let hViewHeight: CGFloat = 200.0
    
        let topView: UIView = {
            let v = UIView()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.backgroundColor = UIColor.blue
            return v
        }()
    
        let holderView: UIView = {
            let v = UIView()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.backgroundColor = UIColor.cyan
            return v
        }()
    
        public override func viewDidLoad() {
            super.viewDidLoad()
            view.bounds = CGRect(x: 0, y: 0, width: 400, height: 600)
            view.backgroundColor = .yellow
    
            // add topView
            view.addSubview(topView)
    
            // pin topView to leading / top / trailing
            topView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
            topView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
            topView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    
            // explicit height for topView
            topView.heightAnchor.constraint(equalToConstant: 250.0).isActive = true
    
            // add holderView to topView
            topView.addSubview(holderView)
    
            // center X and Y
            holderView.centerXAnchor.constraint(equalTo: topView.centerXAnchor, constant: 0.0).isActive = true
            holderView.centerYAnchor.constraint(equalTo: topView.centerYAnchor, constant: 0.0).isActive = true
    
            // aspect ratio size
            holderView.widthAnchor.constraint(equalTo: holderView.heightAnchor, multiplier: hViewWidth / hViewHeight).isActive = true
    
            // two constraints for each side...
            // the .equal constraints need .defaultLow priority
            // top and leading constraints must be .greaterThanOrEqual to 0
            // bottom and trailing constraints must be .lessThanOrEqual to 0
    
            let topA = NSLayoutConstraint(item: holderView, attribute: .top, relatedBy: .greaterThanOrEqual, toItem: topView, attribute: .top, multiplier: 1.0, constant: 0.0)
            let topB = NSLayoutConstraint(item: holderView, attribute: .top, relatedBy: .equal, toItem: topView, attribute: .top, multiplier: 1.0, constant: 0.0)
    
            let bottomA = NSLayoutConstraint(item: holderView, attribute: .bottom, relatedBy: .lessThanOrEqual, toItem: topView, attribute: .bottom, multiplier: 1.0, constant: 0.0)
            let bottomB = NSLayoutConstraint(item: holderView, attribute: .bottom, relatedBy: .equal, toItem: topView, attribute: .bottom, multiplier: 1.0, constant: 0.0)
    
            let leadingA = NSLayoutConstraint(item: holderView, attribute: .leading, relatedBy: .greaterThanOrEqual, toItem: topView, attribute: .leading, multiplier: 1.0, constant: 0.0)
            let leadingB = NSLayoutConstraint(item: holderView, attribute: .leading, relatedBy: .equal, toItem: topView, attribute: .leading, multiplier: 1.0, constant: 0.0)
    
            let trailingA = NSLayoutConstraint(item: holderView, attribute: .trailing, relatedBy: .lessThanOrEqual, toItem: topView, attribute: .trailing, multiplier: 1.0, constant: 0.0)
            let trailingB = NSLayoutConstraint(item: holderView, attribute: .trailing, relatedBy: .equal, toItem: topView, attribute: .trailing, multiplier: 1.0, constant: 0.0)
    
            topB.priority = .defaultLow
            bottomB.priority = .defaultLow
            leadingB.priority = .defaultLow
            trailingB.priority = .defaultLow
    
            NSLayoutConstraint.activate([
                topA, topB,
                bottomA, bottomB,
                leadingA, leadingB,
                trailingA, trailingB
                ])
    
        }
    
    }