With Auto Layout, how do I make a UIImageView's size dynamic depending on the image?

69,781

Solution 1

The image view's intrinsic size is already dependent on the size of the image. Your assumptions (and constraints are correct).

However, if you've set up your image view in interface builder and have not provided it with an image, then the layout system (interface builder) won't know how big your image view is supposed to be at compile time. Your layout is ambiguous because your image view could be many sizes. This is what throws the errors.

Once you set your image view's image property, then the image view's intrinsic size is defined by the size of the image. If you're setting the view at runtime, then you can do exactly what Anna mentioned and provide interface builder with a "placeholder" intrinsic size in the property inspector of the image view. This tells interface builder, "use this size for now, I'll give you a real size later". The placeholder constraints are ignored at runtime.

Your other option is to assign the image to the image view in interface builder directly (but I assume your images are dynamic, so this won't work for you).

Solution 2

Here is the essential problem: the only way in which UIImageView interacts with Auto Layout is via its intrinsicContentSize property. That property provides the intrinsic size of the image itself, and nothing more. This creates three situations, with different solutions.

case 1: view with image's size

In the first case, if you place an UIImageView in a context where external Auto Layout constraints affect only its position, then the view's intrinsicContentSize will declare it wants to be the size of its image, and Auto Layout will automatically resize the view to equal the size of its image. This is sometimes exactly what you want, and then life is good!

case 2: fully constrained view size, preserving image's aspect ratio

At other times, you're in a different case. You know exactly the size you want the image view to take, but you also want it to preserve the aspect ratio of the displayed image. In this case, you can use Auto Layout to constrain the size of the image, while setting the old contentMode property to .scaleAspectFit on the image view. That property will cause the image view to rescale the displayed image, adding padding as needed. And if this is what you want, life is good!

case 3: partially constrained view size, adapting to image aspect ratio

But often times, you're in the tricky third case. You want to use Auto Layout to partially determine the size of the view, while allowing the image's aspect ratio to determine the other part. For instance, you might want to use Auto Layout constraints to determine one dimension of the size (like the width, in a vertical timeline scroll), while relying on the view to tell Auto Layout that it wants a height matching the image's aspect ratio (so the scrollable items are not too short or too tall).

You cannot configure an ordinary image view to do this because because an image view only communicates its intrinsicContentSize. So if you put an image view in a context where Auto Layout constrains one dimension (for instance, constraining it to a short width), then the image view does not tell Auto Layout it wants its height rescaled in tandem. It continues to report its intrinsic content size, with the unmodified height of the image itself.

In order to configure the image view to tell Auto Layout that it wants to take on the aspect ratio of the image it contains, you need to add a new constraint to that effect. Furthermore, you will need to update that constraint whenever the image itself is updated. You can build a UIImageView subclass that does this, so the behavior is automatic. Here's an example:

public class ScaleAspectFitImageView : UIImageView {
  /// constraint to maintain same aspect ratio as the image
  private var aspectRatioConstraint:NSLayoutConstraint? = nil

  required public init?(coder aDecoder: NSCoder) {
    super.init(coder:aDecoder)
    self.setup()
  }

  public override init(frame:CGRect) {
    super.init(frame:frame)
    self.setup()
  }

  public override init(image: UIImage!) {
    super.init(image:image)
    self.setup()
  }

  public override init(image: UIImage!, highlightedImage: UIImage?) {
    super.init(image:image,highlightedImage:highlightedImage)
    self.setup()
  }

  override public var image: UIImage? {
    didSet {
      self.updateAspectRatioConstraint()
    }
  }

  private func setup() {
    self.contentMode = .scaleAspectFit
    self.updateAspectRatioConstraint()
  }

  /// Removes any pre-existing aspect ratio constraint, and adds a new one based on the current image
  private func updateAspectRatioConstraint() {
    // remove any existing aspect ratio constraint
    if let c = self.aspectRatioConstraint {
      self.removeConstraint(c)
    }
    self.aspectRatioConstraint = nil

    if let imageSize = image?.size, imageSize.height != 0
    {
      let aspectRatio = imageSize.width / imageSize.height
      let c = NSLayoutConstraint(item: self, attribute: .width,
                                 relatedBy: .equal,
                                 toItem: self, attribute: .height,
                                 multiplier: aspectRatio, constant: 0)
      // a priority above fitting size level and below low
      c.priority = (UILayoutPriorityDefaultLow + UILayoutPriorityFittingSizeLevel) / 2.0
      self.addConstraint(c)
      self.aspectRatioConstraint = c
    }
  }
}

More comment is available at this gist

Solution 3

For @algal case 3, all I had to do was

class ScaledHeightImageView: UIImageView {

    override var intrinsicContentSize: CGSize {

        if let myImage = self.image {
            let myImageWidth = myImage.size.width
            let myImageHeight = myImage.size.height
            let myViewWidth = self.frame.size.width

            let ratio = myViewWidth/myImageWidth
            let scaledHeight = myImageHeight * ratio

            return CGSize(width: myViewWidth, height: scaledHeight)
        }
        return CGSize(width: -1.0, height: -1.0)
    }
}

This scales the height component of the intrinsicContentSize. (-1.0, -1.0) is returned when there is no image because this is the default behavior in the superclass.

Also, I did not need to set UITableViewAutomaticDimension for the table with the cell containing the image view, and the cell still sizes automatically. The image view's content mode is Aspect Fit.

Solution 4

Here is an AspectFit UIImageView-derived implementation that works with Auto Layout when only one dimension is constrained. The other one will be set automatically. It will keep image aspect ratio and doesn't add any margins around the image.

It's tweaked Objective-C implementation of @algal idea with the following differences:

  • Expression priority = (UILayoutPriorityDefaultLow + UILayoutPriorityFittingSizeLevel) / 2.0 which evaluates to 150 isn't enough to beat the priority of 1000 of the default image content size constraint. So aspect constraint priority was increased to 1000 as well.
  • it removes\re-adds the aspect ratio constraint only if necessary. Keep in mind that this is heavy operation so there is no need to do so if aspect ratio is the same. Moreover, image setter could be called multiple times, so it's a good idea to not cause extra layout calculations here.
  • not sure why we need to override required public init?(coder aDecoder: NSCoder) and public override init(frame:CGRect), so these two are taken out. They aren't overridden by UIImageView, that's why there is no point of adding the aspect constraint until an image is set.

AspectKeepUIImageView.h:

NS_ASSUME_NONNULL_BEGIN

@interface AspectKeepUIImageView : UIImageView

- (instancetype)initWithImage:(nullable UIImage *)image;
- (instancetype)initWithImage:(nullable UIImage *)image highlightedImage:(nullable UIImage *)highlightedImage;

@end

NS_ASSUME_NONNULL_END

AspectKeepUIImageView.m:

#import "AspectKeepUIImageView.h"

@implementation AspectKeepUIImageView
{
    NSLayoutConstraint *_aspectContraint;
}

- (instancetype)initWithImage:(nullable UIImage *)image
{
    self = [super initWithImage:image];

    [self initInternal];

    return self;
}

- (instancetype)initWithImage:(nullable UIImage *)image highlightedImage:(nullable UIImage *)highlightedImage
{
    self = [super initWithImage:image highlightedImage:highlightedImage];

    [self initInternal];

    return self;
}

- (void)initInternal
{
    self.contentMode = UIViewContentModeScaleAspectFit;
    [self updateAspectConstraint];
}

- (void)setImage:(UIImage *)image
{
    [super setImage:image];
    [self updateAspectConstraint];
}

- (void)updateAspectConstraint
{
    CGSize imageSize = self.image.size;
    CGFloat aspectRatio = imageSize.height > 0.0f
        ? imageSize.width / imageSize.height
        : 0.0f;
    if (_aspectContraint.multiplier != aspectRatio)
    {
        [self removeConstraint:_aspectContraint];

        _aspectContraint =
        [NSLayoutConstraint constraintWithItem:self
                                     attribute:NSLayoutAttributeWidth
                                     relatedBy:NSLayoutRelationEqual
                                        toItem:self
                                     attribute:NSLayoutAttributeHeight
                                    multiplier:aspectRatio
                                      constant:0.f];

        _aspectContraint.priority = UILayoutPriorityRequired;

        [self addConstraint:_aspectContraint];
    }
}

@end

Solution 5

I used @algal's excellent answer (case #3) and improved it for my use case.

I wanted to be able to set min and max ratio constraints. So if I have a minimum ratio constraint of 1.0 and set a picture that is higher than wide, the size should be square. Also it should fill the UIImageView in those cases.

This works even in Interface Builder. Just add a UIImageView, set RatioBasedImageView as custom class in the identity inspector and set the max and min aspect ratios in the attributes inspector.

Here's my code.

@IBDesignable
public class RatioBasedImageView : UIImageView {
    /// constraint to maintain same aspect ratio as the image
    private var aspectRatioConstraint:NSLayoutConstraint? = nil

    // This makes it use the correct size in Interface Builder
    public override func prepareForInterfaceBuilder() {
        invalidateIntrinsicContentSize()
    }

    @IBInspectable
    var maxAspectRatio: CGFloat = 999 {
        didSet {
            updateAspectRatioConstraint()
        }
    }

    @IBInspectable
    var minAspectRatio: CGFloat = 0 {
        didSet {
            updateAspectRatioConstraint()
        }
    }


    required public init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        self.setup()
    }

    public override init(frame:CGRect) {
        super.init(frame:frame)
        self.setup()
    }

    public override init(image: UIImage!) {
        super.init(image:image)
        self.setup()
    }

    public override init(image: UIImage!, highlightedImage: UIImage?) {
        super.init(image:image,highlightedImage:highlightedImage)
        self.setup()
    }

    override public var image: UIImage? {
        didSet { self.updateAspectRatioConstraint() }
    }

    private func setup() {
        self.updateAspectRatioConstraint()
    }

    /// Removes any pre-existing aspect ratio constraint, and adds a new one based on the current image
    private func updateAspectRatioConstraint() {
        // remove any existing aspect ratio constraint
        if let constraint = self.aspectRatioConstraint {
            self.removeConstraint(constraint)
        }
        self.aspectRatioConstraint = nil

        if let imageSize = image?.size, imageSize.height != 0 {
            var aspectRatio = imageSize.width / imageSize.height            
            aspectRatio = max(minAspectRatio, aspectRatio)
            aspectRatio = min(maxAspectRatio, aspectRatio)

            let constraint = NSLayoutConstraint(item: self, attribute: .width,
                                       relatedBy: .equal,
                                       toItem: self, attribute: .height,
                                       multiplier: aspectRatio, constant: 0)
            constraint.priority = .required
            self.addConstraint(constraint)
            self.aspectRatioConstraint = constraint
        }
    }
}
Share:
69,781
Doug Smith
Author by

Doug Smith

I'm a web designer playing around with iOS from England

Updated on November 28, 2020

Comments

  • Doug Smith
    Doug Smith over 3 years

    I want my UIImageView to grow or shrink depending on the size of what the actual image it's displaying is. But I want it to stay vertically centered and 10pts from the leading edge of the superview.

    However, if I set these two constraints it complains I haven't set up enough constraints to satisfy Auto Layout. To me it seems to perfectly describe what I want. What am I missing?

  • devios1
    devios1 over 8 years
    This doesn't affect Auto Layout.
  • Alexander Abakumov
    Alexander Abakumov almost 7 years
    Why do you remove / re-add the constraint each time instead of just updating its properties? Removing and re-adding are two heavy operations causing AutoLayout to recalculate the whole layout two times, which is very well noticeable during scrolling, for instance.
  • algal
    algal almost 7 years
    @AlexanderAbakumov The only property of a constraint which you can update at runtime is, ironically, its constant. But if the constraint enforces aspect ratio, that must be expressed by the multiplier. So you have to add/remove the constraint.
  • Alexander Abakumov
    Alexander Abakumov almost 7 years
    Sorry, my bad, you're right - multiplier property is readonly. But in this case still you don't need to re-add the aspect ratio constraint unless the ratio is different. I noticed that updateAspectRatioConstraint could be called multiple times even if i set an image once, so that could save extra layout rearrangements. See my answer here.
  • Jonny
    Jonny about 6 years
    The UILayoutPriority* math needs some Swift 4 updates... I'm giving this a go c.priority = UILayoutPriority.init( (UILayoutPriority.defaultLow.rawValue + UILayoutPriority.fittingSizeLevel.rawValue) / 2.0 )
  • Jonny
    Jonny about 6 years
    I've found that using required (1000) priority for this kind of aspect ratio constraint causes autolayout errors at runtime. Assuming we are talking about table row cells where the size of the image should determine the height of the cell...
  • Jonny
    Jonny about 6 years
    Okay and I actually ended up using c.priority = UILayoutPriority.init(999.0) because the low priority in this answer wouldn't do it. Maybe it comes down to what other constraints you have on your imageview, but I only have four; top/bottom/trailing/leading to the cell contentView.
  • Marc
    Marc almost 6 years
    When used in a StackView, I encountered a case where intrinsicContentSize is not recomputed when view bounds changes, so I had to call invalidateIntrinsicContentSize() from layoutSubviews(): gist.github.com/marcc-orange/e309d86275e301466d1eecc8e400ad0‌​0
  • Laurent Maquet
    Laurent Maquet over 5 years
    I spent my day trying to understand why I couldn't configure case 3 ! @algal You provided THE explanation I was missing !! Thank you so much !
  • Sonius
    Sonius over 5 years
    I needed to write c.isActive = true after initializing and set the priority to 1000 to let this works. But it works now, so thank you!
  • de.
    de. almost 5 years
    there should be a setting for this in IB. Like img{height: auto}
  • CharlesA
    CharlesA almost 4 years
    After spending a few hours on this, I ended up adding if self.tableView.visibleCells.contains(cell) { self.tableView.reloadRows(at: [indexPath], with: .automatic)} to get this to work - in the completion handler for when the image is returned, and after I set the imageView image to the downloaded image.
  • Balázs Vincze
    Balázs Vincze about 3 years
    Thanks a ton! Case 3. got me too, could not figure out why it was not working. Your explanation shined a light on the root of the issue (intrinsicContentSize) and checking the the documentation I came across this: "This intrinsic size must be independent of the content frame, because there’s no way to dynamically communicate a changed width to the layout system based on a changed height, for example."