iosswiftuikituiimagecgcontext

Saving a UIImage cropped to a CGPath


I am trying to mask a UIImage and then save the masked image. So far, I have got this working when displayed in a UIImageView preview as follows:

let maskLayer = CAShapeLayer()
let maskPath = shape.cgPath
maskLayer.path = maskPath.resized(to: imageView.frame)
maskLayer.fillRule = .evenOdd
imageView.layer.mask = maskLayer

let picture = UIImage(named: "1")!
imageView.contentMode = .scaleAspectFit
imageView.image = picture

where shape is a UIBezierPath().

The resized function is:

extension CGPath {
    func resized(to rect: CGRect) -> CGPath {
        let boundingBox = self.boundingBox
        let boundingBoxAspectRatio = boundingBox.width / boundingBox.height
        let viewAspectRatio = rect.width / rect.height
        let scaleFactor = boundingBoxAspectRatio > viewAspectRatio ?
            rect.width / boundingBox.width :
            rect.height / boundingBox.height

        let scaledSize = boundingBox.size.applying(CGAffineTransform(scaleX: scaleFactor, y: scaleFactor))
        let centerOffset = CGSize(
            width: (rect.width - scaledSize.width) / (scaleFactor * 2),
            height: (rect.height - scaledSize.height) / (scaleFactor * 2)
        )

        var transform = CGAffineTransform.identity
            .scaledBy(x: scaleFactor, y: scaleFactor)
            .translatedBy(x: -boundingBox.minX + centerOffset.width, y: -boundingBox.minY + centerOffset.height)

        return copy(using: &transform)!
    }
}

So this works in terms of previewing the outcome I'd like. I'd now like to save this modified UIImage to the user's photo album, in it's original size (so basically generate it again but don't resize the image to fit a UIImageView - keep it as-is and apply the mask over it).

I have tried this, but it just saves the original image - no path/mask applied:

func getMaskedImage(path: CGPath) {
    let picture = UIImage(named: "1")!
    UIGraphicsBeginImageContext(picture.size)

    if let context = UIGraphicsGetCurrentContext() {
        let pathNew = path.resized(to: CGRect(x: 0, y: 0, width: picture.size.width, height: picture.size.height))
        context.addPath(pathNew)
        context.clip()

        picture.draw(in: CGRect(x: 0.0, y: 0.0, width: picture.size.width, height: picture.size.height))

        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        UIImageWriteToSavedPhotosAlbum(newImage!, nil, nil, nil)
    }
}

What am I doing wrong? Thanks.


Solution

  • Don't throw away your layer-based approach! You can still use that when drawing in a graphics context.

    Example:

    func getMaskedImage(path: CGPath) -> UIImage? {
        let picture = UIImage(named: "my_image")!
        let imageLayer = CALayer()
        imageLayer.frame = CGRect(origin: .zero, size: picture.size)
        imageLayer.contents = picture.cgImage
        let maskLayer = CAShapeLayer()
        let maskPath = path.resized(to: CGRect(origin: .zero, size: picture.size))
        maskLayer.path = maskPath
        maskLayer.fillRule = .evenOdd
        imageLayer.mask = maskLayer
        UIGraphicsBeginImageContext(picture.size)
        defer { UIGraphicsEndImageContext() }
    
        if let context = UIGraphicsGetCurrentContext() {
            imageLayer.render(in: context)
            
            let newImage = UIGraphicsGetImageFromCurrentImageContext()
    
            return newImage
        }
        return nil
    }
    

    Note that this doesn't actually resize the image. If you want the image resized, you should get the boundingBox of the resized path. Then do this instead:

    // create a context as big as the bounding box
    UIGraphicsBeginImageContext(boundingBox.size)
    defer { UIGraphicsEndImageContext() }
    
    if let context = UIGraphicsGetCurrentContext() {
        // move the context to the top left of the path
        context.translateBy(x: -boundingBox.origin.x, y: -boundingBox.origin.y)
        imageLayer.render(in: context)
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
    
        return newImage
    }