Search code examples
iosuiimageview

UIImageView with fixed height: How can I get the image view width to grow or shrink to match the aspect ratio of the image?


I am not supplying code because this is purely a conceptual "how to achieve this" question. But I have listed my attempts and the constraints below.

I've tried a few different strategies to achieve this. Basically, I have variable width images and would like the image to line up perfectly on the left and maintain the 8px spacing with the label on the right. See the images below!

The views themselves are inside a vertical UIStackView. This row a horizontal UIStackView but I've also tried it with a simple UIView container. The only constraint set is on the UIImageView, fixed height of 20px. But I'm looking for any viable strategy to achieve variable width, consistent margins behavior.

I've tried setting the compressionResistence/contentHuggingPriority low on the UIImageView and every combination in between. Is this just not possible?

I've highlighted the background blue to demonstrate the issue

Undesired Behavior: not left aligned, extra space between text Wrong image: not left aligned, extra space between text

Desired Behavior: no wasted space, everything lines up Correct image: no wasted space, everything lines up

As shown above, the Blue image view is 20 x 40 points, with Content Mode set to Aspect Fit.

How can I get the image view width to grow or shrink to match the aspect ratio of the image?


Solution

  • To get a UIImageView size to match the image aspect-ratio, you can set the width constraint as a multiple of the height constraint.

    If we want an image view to have a Height of 20-points, and an "aspect-fit" Width:

    imageView.heightAnchor.constraint(equalToConstant: 20.0).isActive = true
    imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: img.size.width / img.size.height).isActive = true
    

    So, let's say we have an image that is 100 x 100 ... we're saying:

    • img.size.width / img.size.height == 1.0
    • widthAnchor equals 20.0 * 1.0
    • resulting imageView frame size is 20, 20

    If our image size is 250 x 100

    • img.size.width / img.size.height == 2.5
    • widthAnchor equals 20.0 * 2.5
    • resulting imageView frame size is 50, 20

    Assuming you have a UIView subclass where the constraints are setup during init, and you are then setting the image, you want to use a "modifiable" constraint as a var / property of your view:

    class SomeCustomView: UIView {
        
        // we'll update this constraint when the small image is set
        private var smallImageWidthConstraint: NSLayoutConstraint!
    

    During initial constraint setup:

    smallImageView.heightAnchor.constraint(equalToConstant: 20.0).isActive = true
    
    // small image width constraint - will be modified when we set the image
    //  small image default is 20x40
    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalToConstant: 40.0)
    smallImageWidthConstraint.isActive = true
    

    When the .image is set, we update that constraint:

    // de-activate
    smallImageWidthConstraint.isActive = false
    
    // set width proportional to height to match image aspect ratio
    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalTo: smallImageView.heightAnchor, multiplier: img.size.width / img.size.height)
    
    // re-activate
    smallImageWidthConstraint.isActive = true
        
    

    Here's a quick example, using these 3 images:

    enter image description here

    enter image description here

    enter image description here

    In assets, I named them p272x120, p91x200 and p232x209 since those are the actual image dimensions.

    Using this custom view:

    class SomeCustomView: UIView {
        
        public var bigImage: UIImage? {
            didSet { bigImageView.image = bigImage }
        }
        public var topString: String = "" {
            didSet { topLabel.text = topString }
        }
        public var sideString: String = "" {
            didSet { sideLabel.text = sideString }
        }
        
        // when we set the small image, we also update the width constraint
        public var smallImage: UIImage? {
            didSet {
                smallImageView.image = smallImage
                // unwrap optional
                if let img = smallImage {
                    // de-activate
                    smallImageWidthConstraint.isActive = false
                    // set width proportional to height to match image aspect ratio
                    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalTo: smallImageView.heightAnchor, multiplier: img.size.width / img.size.height)
                    // re-activate
                    smallImageWidthConstraint.isActive = true
                }
            }
        }
    
        private let bigImageView = UIImageView()
        private let smallImageView = UIImageView()
        private let topLabel: UILabel = {
            let v = UILabel()
            v.font = .systemFont(ofSize: 15.0, weight: .bold)
            v.numberOfLines = 0
            return v
        }()
        private let subLabel: UILabel = {
            let v = UILabel()
            v.font = .italicSystemFont(ofSize: 14.0)
            v.numberOfLines = 0
            return v
        }()
        private let sideLabel: UILabel = {
            let v = UILabel()
            v.font = .systemFont(ofSize: 12.0, weight: .bold)
            v.numberOfLines = 0
            return v
        }()
        
        // we'll update this constraint when the small image is set
        private var smallImageWidthConstraint: NSLayoutConstraint!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            
            // horizontal stack view to hold
            //  small image and side label
            let hStack = UIStackView(arrangedSubviews: [smallImageView, sideLabel])
            hStack.spacing = 8
            hStack.alignment = .top
    
            // vertical stack view to hold labels and ssmall image
            let vStack = UIStackView(arrangedSubviews: [topLabel, subLabel, hStack])
            vStack.axis = .vertical
            vStack.spacing = 8
    
            [bigImageView, vStack].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                addSubview(v)
            }
    
            // small image width constraint - will be modified when we set the image
            //  small image default is 20x40
            smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalToConstant: 40.0)
            
            NSLayoutConstraint.activate([
                
                // big image 80x80
                bigImageView.widthAnchor.constraint(equalToConstant: 80.0),
                bigImageView.heightAnchor.constraint(equalToConstant: 80.0),
                // leading
                bigImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
                // center vertically
                bigImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
                // at least 12-points top/bottom
                bigImageView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 12.0),
                bigImageView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12.0),
                
                // vStack leading 8-points from big image
                vStack.leadingAnchor.constraint(equalTo: bigImageView.trailingAnchor, constant: 8.0),
                // center vStack vertically
                vStack.centerYAnchor.constraint(equalTo: centerYAnchor),
                // at least 12-points top/bottom
                vStack.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 12.0),
                vStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12.0),
                // trailing
                vStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
                
                // small image height 20-points
                smallImageView.heightAnchor.constraint(equalToConstant: 20.0),
                // activate the "changeable" width constraint
                smallImageWidthConstraint,
                
            ])
            
            // let's set some properties
            
            backgroundColor = UIColor(white: 0.9, alpha: 1.0)
            
            // edit this after development...
    
            // big image will be set by controller
            bigImageView.backgroundColor = .systemRed
            if let img = UIImage(systemName: "swift") {
                bigImageView.image = img
            }
    
            // these will be .clear
            smallImageView.backgroundColor = .systemBlue
            topLabel.backgroundColor = .yellow
            subLabel.backgroundColor = .cyan
            sideLabel.backgroundColor = .green
            
        }
        
        // this is here during development to show
        //  the resulting small imageView size
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // let's use Int values, so we don't output .33333333...
            let w = Int(smallImageView.frame.width)
            let h = Int(smallImageView.frame.height)
            subLabel.text = "Actual size (rounded): (w: \(w), h: \(h))"
        }
        
    }
    

    and this example controller (putting the custom views in a vertical stack view):

    class AspectImageVC: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 20.0
    
            let myData: [[String]] = [
                ["p272x120", "Wide Image", "Nintendo Switch"],
                ["p91x200", "Tall, Narrow Image", "Left Controller"],
                ["p232x209", "Square-ish Image", "Nintendo Cube"],
                ["", "No Image", "So we can see default 20x40 small image view size"],
            ]
    
            myData.forEach { d in
    
                let imgName = d[0]
                let top = d[1]
                let side = d[2]
                
                let someView = SomeCustomView()
                
                if imgName.isEmpty {
                    someView.topString = top
                } else {
                    someView.topString = imgName + " " + top
                    // safely load the image
                    if let img = UIImage(named: imgName) {
                        someView.smallImage = img
                    }
                }
                someView.sideString = side
    
                stackView.addArrangedSubview(someView)
    
            }
    
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            ])
            
        }
        
    }
    

    We get this output:

    enter image description here


    Edit - in response to comment...

    Views can have more than one of "the same" constraint.

    If you want your "small image view" to have a MAX width of 40-points...

    Add a lessThanOrEqualToConstant width constraint:

    smallImageView.heightAnchor.constraint(equalToConstant: 20.0).isActive = true
    smallImageView.widthAnchor.constraint(lessThanOrEqualToConstant: 40.0).isActive = true
    
    // small image width constraint - will be modified when we set the image
    //  small image default is 20x40
    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalToConstant: 40.0)
    smallImageWidthConstraint.isActive = true
    

    By default, constraints have .priority = .required. So, when we set the image and modify the width constraint:

    // de-activate
    smallImageWidthConstraint.isActive = false
    // set width proportional to height to match image aspect ratio
    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalTo: smallImageView.heightAnchor, multiplier: img.size.width / img.size.height)
    // use less-than-required so we can limit its width
    smallImageWidthConstraint.priority = .required - 1
    // re-activate
    smallImageWidthConstraint.isActive = true
    

    So, the SomeCustomView with those modifications:

    class SomeCustomView: UIView {
        
        public var bigImage: UIImage? {
            didSet { bigImageView.image = bigImage }
        }
        public var topString: String = "" {
            didSet { topLabel.text = topString }
        }
        public var sideString: String = "" {
            didSet { sideLabel.text = sideString }
        }
        
        // when we set the small image, we also update the width constraint
        public var smallImage: UIImage? {
            didSet {
                smallImageView.image = smallImage
                // unwrap optional
                if let img = smallImage {
                    // de-activate
                    smallImageWidthConstraint.isActive = false
                    // set width proportional to height to match image aspect ratio
                    smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalTo: smallImageView.heightAnchor, multiplier: img.size.width / img.size.height)
                    // use less-than-required so we can limit its width
                    smallImageWidthConstraint.priority = .required - 1
                    // re-activate
                    smallImageWidthConstraint.isActive = true
                }
            }
        }
    
        private let bigImageView = UIImageView()
        private let smallImageView: UIImageView = {
            let v = UIImageView()
            v.contentMode = .scaleAspectFit
            return v
        }()
        private let topLabel: UILabel = {
            let v = UILabel()
            v.font = .systemFont(ofSize: 15.0, weight: .bold)
            v.numberOfLines = 0
            return v
        }()
        private let subLabel: UILabel = {
            let v = UILabel()
            v.font = .italicSystemFont(ofSize: 14.0)
            v.numberOfLines = 0
            return v
        }()
        private let sideLabel: UILabel = {
            let v = UILabel()
            v.font = .systemFont(ofSize: 12.0, weight: .bold)
            v.numberOfLines = 0
            return v
        }()
        
        // we'll update this constraint when the small image is set
        private var smallImageWidthConstraint: NSLayoutConstraint!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            
            // horizontal stack view to hold
            //  small image and side label
            let hStack = UIStackView(arrangedSubviews: [smallImageView, sideLabel])
            hStack.spacing = 8
            hStack.alignment = .center
    
            // vertical stack view to hold labels and ssmall image
            let vStack = UIStackView(arrangedSubviews: [topLabel, subLabel, hStack])
            vStack.axis = .vertical
            vStack.spacing = 8
    
            [bigImageView, vStack].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                addSubview(v)
            }
    
            // small image width constraint - will be modified when we set the image
            //  small image default is 20x40
            smallImageWidthConstraint = smallImageView.widthAnchor.constraint(equalToConstant: 40.0)
            
            NSLayoutConstraint.activate([
                
                // big image 80x80
                bigImageView.widthAnchor.constraint(equalToConstant: 80.0),
                bigImageView.heightAnchor.constraint(equalToConstant: 80.0),
                // leading
                bigImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
                // center vertically
                bigImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
                // at least 12-points top/bottom
                bigImageView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 12.0),
                bigImageView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12.0),
                
                // vStack leading 8-points from big image
                vStack.leadingAnchor.constraint(equalTo: bigImageView.trailingAnchor, constant: 8.0),
                // center vStack vertically
                vStack.centerYAnchor.constraint(equalTo: centerYAnchor),
                // at least 12-points top/bottom
                vStack.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 12.0),
                vStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12.0),
                // trailing
                vStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
                
                // small image height 20-points
                smallImageView.heightAnchor.constraint(equalToConstant: 20.0),
                // MAX width of 40-points
                smallImageView.widthAnchor.constraint(lessThanOrEqualToConstant: 40.0),
                // activate the "changeable" width constraint
                smallImageWidthConstraint,
                
            ])
            
            // let's set some properties
            
            backgroundColor = UIColor(white: 0.9, alpha: 1.0)
            
            // edit this after development...
    
            // big image will be set by controller
            bigImageView.backgroundColor = .systemRed
            bigImageView.tintColor = .systemYellow
            if let img = UIImage(systemName: "swift") {
                bigImageView.image = img
            }
    
            // these will be .clear
            smallImageView.backgroundColor = .systemBlue
            topLabel.backgroundColor = .yellow
            subLabel.backgroundColor = .cyan
            sideLabel.backgroundColor = .green
            
        }
        
        // this is here during development to show
        //  the resulting small imageView size
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // let's use Int values, so we don't output .33333333...
            let w = Int(smallImageView.frame.width)
            let h = Int(smallImageView.frame.height)
            subLabel.text = "Actual size (rounded): (w: \(w), h: \(h))"
        }
        
    }
    

    and the output is now:

    enter image description here

    Note that the top image is only 40-points wide, instead of 45 as before.