0

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:0x6000000a7c80@ViewController.swift#24 UIView:0x7fcd82d12440.left == UIView:0x7fcd82d12640.left>",
"<SnapKit.LayoutConstraint:0x6000000a7ce0@ViewController.swift#25 UIView:0x7fcd82d12440.trailing == UIView:0x7fcd82d12640.trailing>",
"<SnapKit.LayoutConstraint:0x6000000a7d40@ViewController.swift#26 UIView:0x7fcd82d12440.height == 250.0>",
"<SnapKit.LayoutConstraint:0x6000000a7da0@ViewController.swift#35 UIView:0x7fcd8580dad0.centerX == UIView:0x7fcd82d12440.centerX>",
"<SnapKit.LayoutConstraint:0x6000000a7e00@ViewController.swift#35 UIView:0x7fcd8580dad0.centerY == UIView:0x7fcd82d12440.centerY>",
"<SnapKit.LayoutConstraint:0x6000000a8580@ViewController.swift#37 UIView:0x7fcd8580dad0.top >= UIView:0x7fcd82d12440.top>",
"<SnapKit.LayoutConstraint:0x6000000a8a60@ViewController.swift#37 UIView:0x7fcd8580dad0.right >= UIView:0x7fcd82d12440.right>",
"<SnapKit.LayoutConstraint:0x6000000a9360@ViewController.swift#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:0x6000000a9360@ViewController.swift#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

SwiftedMind
  • 3,701
  • 3
  • 29
  • 63
  • Anyreason why the imageView is not simply linked to the holder edges and using `.scaleAspectFit` ? – yageek Mar 16 '18 at 15:15
  • hard to explain, but I'll try: I've got a `collection view` that is full of differently sized cells. Each cell is covered by a `UIImageView` (linked to the cell's edges) set to `.scaleAspectFill`. Now I need a smaller representation of what the cell with the image looks like in another `view controller`, where you can move it around to place/rotate/scale the image (aspectFill 'zooms' the image to fit, and you should be able to kind of say how it's placed in the cell) – SwiftedMind Mar 16 '18 at 15:23
  • I realize it's not so much about an `ImageView`, really. I'm gonna edit the title. The image will indeed just be linked to the `holderView`s edges. Its the holder view that has to have the aspect ratio of the cell. Sorry, I mixed that up – SwiftedMind Mar 16 '18 at 15:29
  • You have way too many constraints. You can either constrain two edges and the width/height **or** the center and width/height **or** four edges (and if you are using an aspect ratio constraint, set only width **or** height) – Paulw11 Mar 16 '18 at 15:32
  • It looks like you're trying to set the `width` of the imageView equal to the `height` of the imageView * 3/2... but you are *also* setting the edges of the imageView to its superview. You probably want to set one or the other, not both. – DonMag Mar 16 '18 at 15:34
  • You can also use http://wtfautolayout.com for help – Paulw11 Mar 16 '18 at 15:35
  • It's working, though. At least in the storyboard it's not giving me any errors or warnings. I don't see why it shouldn't work, to be honest. If the aspect ratio is 1:1, for instance, and the square is not as wide as the screen (`topView`) the square will have the same height as `topView`. Width would be the same (1:1). If would be placed in the center of `topView`. Not a single constraint would be violated (width/height is okay, its centered, and its >=0 from the edges, which is required) – SwiftedMind Mar 16 '18 at 15:44
  • @DonMag yes, but I'm giving them the requirement to be "greater than or equal to 0" from the edges. The "=0" from edges constraints have low priority so Auto-Layout won't force it – SwiftedMind Mar 16 '18 at 15:45
  • @Quantm - you are setting `topView` to the full width of its superview, with a height of `250` ... do you want your imageView to stretch the full *width*, and allow the aspect ratio to control its *height*? Or do you want the imageView to stretch the full *height* and allow the aspect ratio to control its *width*? – DonMag Mar 16 '18 at 15:59
  • That's the critical point (that's where I was before as well): It depends on the aspect ratio. I tried to visualize it: https://imgur.com/a/SF1LJ. It has to try and fit so that everything is visible, on-screen (that's what the ">=0" constraints are there for. To guarantee that) – SwiftedMind Mar 16 '18 at 16:10
  • (Maybe I haven't explained it that well in the question. I'm sorry if that's the case. It's hard to explain these kinds of things in a written text) – SwiftedMind Mar 16 '18 at 16:15
  • hmm... so, you want to "scale aspect fit" the `holderVIew` subview inside the `topView` superview? Centered either vertically or horizontally as appropriate? – DonMag Mar 16 '18 at 17:21
  • Yes, that's basically it (that's more on-point than what I wrote :D). But it should be centered vertically *and* horizontally. – SwiftedMind Mar 16 '18 at 17:24

1 Answers1

1

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
            ])

    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks for the answer. I had a similar version of this before (though it didn't quite work. Your version works perfectly), but I find this slightly inconvenient when it *should* work without manually calculating anything. (It's working using storyboard, so it *has to work* using code only as well. That's what I don't understand). But I guess I'd have to stick with calculation width/heigh manually if nobody has an idea why the code isn't working. Thanks anyways. – SwiftedMind Mar 16 '18 at 18:17
  • @Quantm - Ah --- now I understand. See my edited answer, specifically the comments. – DonMag Mar 16 '18 at 18:56
  • wow, this does work (though I think it should be `hViewHeight / hViewWidth`, not `hViewWidth / hViewHeight`). But why should it be lessThanOrEqualTo? I don't quite get that. Shouldn't this mean that parts can be off-screen? But a huge thank you for this effort. I really appreciate it! – SwiftedMind Mar 16 '18 at 19:10
  • 1
    Whoops - I transposed the width-to-height ratio... fixed now. When you set the trailing and bottom constraints in storyboard, IB automatically sets First Item: Superview, Second Item: Subview... so it is `>= 0`. Setting it from code is *generally* easier to think in terms of "subview-to-superview" so you need to make it `<= 0`. Very common when coding to "I want it 20-pts from the bottom" and getting it wrong when setting it to `+20` instead of `-20`. – DonMag Mar 16 '18 at 19:15
  • ooooohhhh, dang it. Right, I never thought about that. Makes sense. Thank you! :) – SwiftedMind Mar 16 '18 at 19:17