Stack View ScaleAspectFit Mask Resize in Swift

2020-05-08 08:35发布

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. :-(

1条回答
▲ chillily
2楼-- · 2020-05-08 08:56

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.

查看更多
登录 后发表回答