Search code examples
iosuiimageviewcashapelayer

Masking a donut shape in a UIImageView


I am trying to cut a donut shape into a UIImageView. I can currently only achieve a circle cutout like so:

import UIKit
import CoreGraphics

class AvatarImageView: UIImageView {

    enum AvatarImageViewOnlineStatus {
        case online
        case offline
        case hidden
    }

    var rounded: Bool = false
    var onlineStatus: AvatarImageViewOnlineStatus = .hidden

    override func layoutSubviews() {
        super.layoutSubviews()
        guard !rounded else { return }
        if let image = image, !rounded {
            self.image = image.af_imageRoundedIntoCircle()
            rounded = true
        }

        let rect: CGRect = CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height)
        let maskLayer: CAShapeLayer = CAShapeLayer()
        maskLayer.frame = rect

        let mainPath: UIBezierPath = UIBezierPath(rect: rect)
        let circlePath: UIBezierPath = UIBezierPath(ovalIn: CGRect(x: 20, y: 20, width: 30, height: 30))
        mainPath.append(circlePath)

        maskLayer.fillRule = kCAFillRuleEvenOdd
        maskLayer.path = mainPath.cgPath
        maskLayer.strokeColor = UIColor.clear.cgColor
        maskLayer.lineWidth = 10.0
        layer.mask = maskLayer
    }

}

enter image description here

Is it possible to create a donut shaped cutout using masks?


Solution

  • This took a little more effort then I was expecting and the solution wasn't obvious ... until I found it, then it was like, oh, yeah, sure :/

    The "basic" idea is, you want to "cut out" a section of the an existing path, the solution wasn't obvious, because UIBezierPath doesn't provide a "subtract" or "remove" method. Instead, you need to "reverse" the path, for example...

    let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 200, height: 200))
    path.append(UIBezierPath(ovalIn: CGRect(x: 50, y: 50, width: 100, height: 100)).reversing())
    path.append(UIBezierPath(ovalIn: CGRect(x: 75, y: 75, width: 50, height: 50)))
    

    And, because "out-of-context" snippets are rarely helpful, this is the playground I used to test it (supply own image)...

    import UIKit
    import Foundation
    import CoreGraphics
    
    let image = UIImage(named: "Profile")
    let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
    let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
    backgroundView.backgroundColor = UIColor.green
    backgroundView.addSubview(imageView)
    
    imageView.contentMode = .scaleAspectFit
    imageView.image = image
    imageView.layer.cornerRadius = 100
    
    let shapeLayer = CAShapeLayer()
    
    let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 200, height: 200))
    path.append(UIBezierPath(ovalIn: CGRect(x: 50, y: 50, width: 100, height: 100)).reversing())
    path.append(UIBezierPath(ovalIn: CGRect(x: 75, y: 75, width: 50, height: 50)))
    
    shapeLayer.path = path.cgPath
    shapeLayer.fillColor = UIColor.red.cgColor
    shapeLayer.frame = imageView.bounds
    
    imageView.layer.mask = shapeLayer
    backgroundView
    

    And my results...

    Those are the droids you're looking for

    The green color is from the backgroundView on which the UIImageView resides

    Now, if you would prefer something more like...

    I can't see anything out of this helmet

    Then just get rid of the second append statement, path.append(UIBezierPath(ovalIn: CGRect(x: 75, y: 75, width: 50, height: 50)))