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
Desired Behavior: 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?
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
20, 20
If our image size is 250 x 100
img.size.width / img.size.height == 2.5
widthAnchor
equals 20.0 * 2.5
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:
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:
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:
Note that the top image is only 40-points wide, instead of 45 as before.