1

I am masking an image within a stack view and for some odd reason, my mask is not aligning/resizing correctly with the image.

Here is a demonstration of what's occurring as I'm dynamically adding instances of this image in a stack view while each subview is resized within its boundaries and spacing.

Two CirclesFive Circles

As you can see, the mask retains the original size of the image and not the resized version. I've tried many different width & height variations including the bounds.width, layer.frame.width, frame.width, frame.origin.x, etc, and had no luck.

Current code in Swift 2:

let testPicture:UIImageView = UIImageView(image: UIImage(named: "myPicture"))
testPicture.contentMode = .ScaleAspectFit
testPicture.layer.borderWidth = 1
testPicture.clipsToBounds = true
testPicture.layer.masksToBounds = true
view.layer.addSublayer(shapeLayer)

var width = testPicture.layer.frame.width
var height = testPicture.layer.frame.height
let center = CGPointMake(width/2, height/2)
let radius = CGFloat(CGFloat(width) / 2)


// Mask
let yourCarefullyDrawnPath = UIBezierPath()
        yourCarefullyDrawnPath.moveToPoint(center)
        yourCarefullyDrawnPath.addArcWithCenter(center,
            radius: radius,
            startAngle: 0,
            endAngle: CGFloat( (0.80*360.0) * M_PI / 180.0),
            clockwise: true)
yourCarefullyDrawnPath.closePath()

let maskPie = CAShapeLayer()
maskPie.frame = testPicture.layer.bounds
testPicture.clipsToBounds = true
testPicture.layer.masksToBounds = true
maskPie.path = yourCarefullyDrawnPath.CGPath
testPicture.layer.mask = maskPie


// Add Into Stackview
self.myStackView.addArrangedSubview(testPicture)
self.myStackView.layoutIfNeeded()

I suspect that I'm fetching the wrong width and height in order to generate the center and radius variables although after trying all the different widths and heights I can find, I still cannot achieve the correct sizes. :-(

theflarenet
  • 642
  • 2
  • 13
  • 27
  • You'll want to calculate the frame that your aspect fitted image occupies in the image view frame. [My answer here](http://stackoverflow.com/a/35510271/2976878) (although not Swift, but easy enough to convert) might be helpful. – Hamish Mar 22 '16 at 18:55
  • Wow. This looks very clever. I've spent the past hour experimenting with it and I'm quite confused with "aspectFitRect(v.bounds, innerRect);" Would v.bounds be "self.myStackView.bounds" and innerRect be "testImage.bounds" in my case? – theflarenet Mar 22 '16 at 20:27
  • Seeing as you'll be calculating the frame of the *image* that sits in your `UIImageView`, you'll want to use the `UIImageView` bounds for the outer rect (as you're adding the mask as a sublayer to the image view), and the image bounds for the inner rect e.g `CGRect(origin: CGPointZero, size: image.size)` – Hamish Mar 22 '16 at 22:48
  • Thank you. I've set my outer rect values to testPicture.bounds and debugged to see whether it was returning correct sizes however the boundaries remain the same. It doesn't change as I'm dynamically adding new subviews where resizing occurs. I don't understand why and believe I'm fetching the subviews' bounds incorrectly when the 'myStackView' stackview is adding new entries. – theflarenet Mar 23 '16 at 17:00
  • I believe I'm getting closer! I discovered "myStackView.arrangedSubviews[0].bounds" and it's probably what I've been looking for. The bounds seem to be changing as I dynamically add and remove yet the alignment is still way off. :-O – theflarenet Mar 23 '16 at 17:33

1 Answers1

4

You'll want to get the frame the image occupies within the image view.

Unfortunately, UIImageView provides no native support for doing this, however you can calculate this fairly simply. I have already created a function that will take a given outer rect, and a given inner rect and return the inner rect after it's been aspect fitted to sit within the outer rect.

A Swift version of the function would look something like this:

func aspectFitRect(outerRect outerRect:CGRect, innerRect:CGRect) -> CGRect {

    let innerRectRatio = innerRect.size.width/innerRect.size.height; // inner rect ratio
    let outerRectRatio = outerRect.size.width/outerRect.size.height; // outer rect ratio

    // calculate scaling ratio based on the width:height ratio of the rects.
    let ratio = (innerRectRatio > outerRectRatio) ? outerRect.size.width/innerRect.size.width:outerRect.size.height/innerRect.size.height;

    // The x-offset of the inner rect as it gets centered
    let xOffset = (outerRect.size.width-(innerRect.size.width*ratio))*0.5;

    // The y-offset of the inner rect as it gets centered
    let yOffset = (outerRect.size.height-(innerRect.size.height*ratio))*0.5;

    // aspect fitted origin and size
    let innerRectOrigin = CGPoint(x: xOffset+outerRect.origin.x, y: yOffset+outerRect.origin.y);
    let innerRectSize = CGSize(width: innerRect.size.width*ratio, height: innerRect.size.height*ratio);

    return CGRect(origin: innerRectOrigin, size: innerRectSize);
}

The other thing you need to do is subclass UIImageView and override the layoutSubviews method. This is because as you're adding your image views to a UIStackView - you're no longer in control of the frames of your image views. Therefore by overriding layoutSubviews, you'll be able to update your mask whenever the stack view alters the frame of the view.

Something like this should achieve the desired result:

class MaskedImageView: UIImageView {

    let maskLayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    override init(image: UIImage?) {
        super.init(image: image)
        commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    private func commonInit() {

        // configure your common image view properties here
        contentMode = .ScaleAspectFit
        clipsToBounds = true

        // mask your image layer
        layer.mask = maskLayer
    }

    override func layoutSubviews() {

        guard let img = image else { // if there's no image - skip updating the mask.
            return
        }

        // the frame that the image itself will occupy in the image view as it gets aspect fitted
        let imageRect = aspectFitRect(outerRect: bounds, innerRect: CGRect(origin: CGPointZero, size: img.size))

        // update mask frame
        maskLayer.frame = imageRect

        // half the image's on-screen width or height, whichever is smallest
        let radius = min(imageRect.size.width, imageRect.size.height)*0.5

        // the center of the image rect
        let center = CGPoint(x: imageRect.size.width*0.5, y: imageRect.size.height*0.5)

        // your custom masking path
        let path = UIBezierPath()
        path.moveToPoint(center)
        path.addArcWithCenter(center, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI*2.0*0.8), clockwise: true)
        path.closePath()            

        // update mask layer path
        maskLayer.path = path.CGPath
    }
}

You can then create your image views from your view controller and add them to your stack view as normal.

let stackView = UIStackView()

override func viewDidLoad() {
    super.viewDidLoad()

    stackView.frame = view.bounds
    stackView.distribution = .FillProportionally
    stackView.spacing = 10
    view.addSubview(stackView)

    for _ in 0..<5 {
        let imageView = MaskedImageView(image:UIImage(named:"foo.jpg"))
        stackView.addArrangedSubview(imageView)
        stackView.layoutIfNeeded()
    }

}

Gives me the following result:

enter image description here


Unrelated Ramblings...

Just noticed in your code that you're doing this:

testPicture.clipsToBounds = true
testPicture.layer.masksToBounds = true

These both do the same thing.

A UIView is no more than a wrapper for an underlying CALayer. However for convenience, some CALayer properties also have a UIView equivalent. All the UIView equivalent does is forward to message down to the CALayer when it is set, and retrieve a value from the CALayer when it is 'get'ed.

clipsToBounds and masksToBounds are one of these pairs (although annoyingly they don't share the same name).

Try doing the following:

view.layer.masksToBounds = true
print(view.clipsToBounds) // output: true
view.layer.masksToBounds = false
print(view.clipsToBounds) // output: false

view.clipsToBounds = true
print(view.layer.masksToBounds) // output: true
view.clipsToBounds = false
print(view.layer.masksToBounds) // output: false

Seeing as you're working with a UIView, clipToBounds is generally the preferred property to update.

Community
  • 1
  • 1
Hamish
  • 78,605
  • 19
  • 187
  • 280
  • 1
    Wow! This is brilliant and your suggestions & explanations are above and beyond. It really is unfortunate that there is no native support for this implementation... especially when you're a new Swift programmer trying to figure this out. Thank you for helping me as I've attempted to tackle this down on my own for hours. – theflarenet Mar 24 '16 at 15:32
  • @theflarenet happy to help :) – Hamish Mar 24 '16 at 15:35