Search code examples
iosswiftuiimageviewuiimage

Constrain button to image inside UIImageView


I have a UIImageView and the user can set the image so the width/height varies. I would like add a "x"- button to the upper right corner but NOT from the UIImageView but from the actual image.

Here is how it looks right now. The whole picture is the UIImageView, on the left you can see the image and on the right top corner you can see the button.

enter image description here

This is how I constrain it at the moment:

theStackView.addArrangedSubview(self.imageView)
imageContainerView.addSubview(wishImageView)
imageContainerView.addSubview(deleteImageButton)

imageContainerView.heightAnchor.constraint(equalToConstant: 60).isActive = true
imageContainerView.isHidden = true

wishImageView.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor, constant: 20).isActive = true
wishImageView.topAnchor.constraint(equalTo: imageContainerView.topAnchor, constant: 3).isActive = true
wishImageView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor, constant: 3).isActive = true
wishImageView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor, constant: -20).isActive = true

deleteImageButton.widthAnchor.constraint(equalToConstant: 10).isActive = true
deleteImageButton.heightAnchor.constraint(equalToConstant: 10).isActive = true
deleteImageButton.topAnchor.constraint(equalTo: wishImageView.topAnchor, constant: 5).isActive = true
deleteImageButton.trailingAnchor.constraint(equalTo: wishImageView.trailingAnchor, constant: -5).isActive = true

This is obviously wrong but is there a way to constrain it to the actual image?


Solution

  • I put together a sample project that the repo is here.

    Basically you need to do a few things:

    • Calculate both your UIImageView frame size along with the frame of the UIMage being displayed in scaledAspectFit.
    • Create two named constraints and dynamically reposition your button once you have its frame.

    For the first, you need to remember that the frames may not really be set until viewDidLayoutSubviews. I create a UIImageView extension that easily computes where the UIImage frame really is. (It's old but working code. I'm sure it can be improved.)

    extension UIImageView {
        public var scaleFactor:CGFloat {
            guard let image = self.image, self.frame != CGRect.zero  else {
                return 0.0
            }
    
            let frame = self.frame
            let extent = image.size
            let heightFactor = frame.height/extent.height
            let widthFactor = frame.width/extent.width
    
            if extent.height > frame.height || extent.width > frame.width {
                if heightFactor < 1 && widthFactor < 1 {
                    if heightFactor > widthFactor {
                        return widthFactor
                    } else {
                        return heightFactor
                    }
                } else if extent.height > frame.height {
                    return heightFactor
                } else {
                    return widthFactor
                }
            } else if extent.height < frame.height && extent.width < frame.width {
                if heightFactor < widthFactor {
                    return heightFactor
                } else {
                    return widthFactor
                }
            } else {
                return 1
            }
        }
    
        public var imageSize:CGSize {
            if self.image == nil {
                return CGSize.zero
            } else {
                return CGSize(width: (self.image?.size.width)!, height: (self.image?.size.height)!)
            }
        }
    
        public var scaledSize:CGSize {
            guard let image = self.image, self.frame != CGRect.zero  else {
                return CGSize.zero
            }
            let factor = self.scaleFactor
            return CGSize(width: image.size.width * factor, height: image.size.height * factor)
        }
    }
    

    For the second bullet you need to create two variables of type NSConstraint. I adapted my answer from two years ago for this:

    var btnTop:NSLayoutConstraint!
    var btnTrailing:NSLayoutConstraint!
    

    And in `viewDidLoad:

    button.heightAnchor.constraint(equalToConstant: 20).isActive = true
    button.widthAnchor.constraint(equalToConstant: 20).isActive = true
    
    btnTop = button.topAnchor.constraint(equalTo: imageView.topAnchor, constant: 10)
    btnTop.isActive = true
    btnTrailing = button.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: -10)
    btnTrailing.isActive = true
    

    Note that you need to code two lines for each constraint! I never figured out why, but if you try to add the isActive property with the actual constraint the compiler doesn't know the correct type of the variable.

    Now, you tie all this together in viewDidLayoutSubviews:

    let scaledSize = imageView.scaledSize
    var imageFrame = CGRect(origin: CGPoint.zero, size: scaledSize)
    if scaledSize.width == imageView.frame.width {
        // image fills view along width, calculate Y constant
        imageFrame.origin.y = (imageView.frame.height - scaledSize.height) / 2
    } else {
        // image fills view along height, calculate X constant
        imageFrame.origin.x = (imageView.frame.width - scaledSize.width) / 2
    }
    //btnTop.constant = imageFrame.width - 30
    btnTop.constant = imageFrame.origin.y + 10
    btnTrailing.constant = ((imageView.frame.width - imageFrame.width - imageFrame.origin.x) * -1) - 10
    

    Placing the button in the top left is much simpler - it took me a good 20 minutes to get the correct calculation to make it top right instead!

    In my test project I encapsulated this code in repositionCloseButton(), which would be called anytime the app displays a new image. This should work in both portrait and landscape orientation, and both portrait and landscape images - positioning a 20x20 close button 10 points away from the top right of an image.