Home > Mobile >  How do I mask and erase a CAShapeLayer?
How do I mask and erase a CAShapeLayer?

Time:06-01

I am trying to get erase functionality working with CAShapeLayer. Current code:

class MaskTestVC: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        let image = UIImage(named: "test.jpg")!

        let testLayer = CAShapeLayer()
        testLayer.contents = image.cgImage
        testLayer.frame = view.bounds

        let maskLayer = CAShapeLayer()
        maskLayer.opacity = 1.0
        maskLayer.lineWidth = 20.0
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.frame = view.bounds
        testLayer.mask = maskLayer

        view.layer.addSublayer(testLayer)
    }

}

I was thinking if I create a mask layer then I could draw a path on the mask layer and erase parts of the image eg:

let path = UIBezierPath()
path.move(to: CGPoint(x: 100.0, y: 100.0))
path.addLine(to: CGPoint(x: 200.0, y: 200.0))
maskLayer.path = path.cgPath

However it seems that when I add the maskLayer it covers the entire image and I can't see the contents below it. What am I doing wrong here?

CodePudding user response:

maskLayer has no initial path therefore its content is not filled, also filling it with .clear will completely mask the image.

This works for me:

override func viewDidLoad() {
   super.viewDidLoad()
        
   view.backgroundColor = .white
   let image = UIImage(named: "test.jpg")!
       
   let testLayer = CAShapeLayer()
   testLayer.contents = image.cgImage
   testLayer.frame = view.bounds
        
   let maskLayer = CAShapeLayer()
   maskLayer.strokeColor = UIColor.black.cgColor
   maskLayer.fillColor = UIColor.black.cgColor
   maskLayer.path = UIBezierPath(rect: view.bounds).cgPath
   testLayer.mask = maskLayer
        
   view.layer.addSublayer(testLayer)
     
   /* optional for testing   
   let path = UIBezierPath()
   path.move(to: CGPoint(x: 100.0, y: 100.0))
   path.addLine(to: CGPoint(x: 200.0, y: 200.0))
   maskLayer.path = path.cgPath
   */
}

Not actually sure if a combination of stroke and fill works but maybe someone else comes up with a solution. If you want to cut out shapes from your path you could try something like this:

override func viewDidLoad() {
   super.viewDidLoad()
        
   view.backgroundColor = .white
   let image = UIImage(named: "test.jpg")!
        
   let testLayer = CAShapeLayer()
   testLayer.contents = image.cgImage
   testLayer.frame = view.bounds
        
   let maskLayer = CAShapeLayer()
   maskLayer.fillColor = UIColor.black.cgColor
   maskLayer.fillRule = .evenOdd
   testLayer.mask = maskLayer
        
   view.layer.addSublayer(testLayer)
        
   let path = UIBezierPath(rect: CGRect(x: 100, y: 100, width: 200, height: 20))
   let fillPath = UIBezierPath(rect: view.bounds)
   fillPath.append(path)
   maskLayer.path = fillPath.cgPath
}

CodePudding user response:

You can use a bezier path mask to "erase" the path by creating a custom CALayer subclass and overriding draw(in ctx: CGContext):

class MyCustomLayer: CALayer {
    
    var myPath: CGPath?
    var lineWidth: CGFloat = 24.0

    override func draw(in ctx: CGContext) {

        // fill entire layer with solid color
        ctx.setFillColor(UIColor.gray.cgColor)
        ctx.fill(self.bounds);

        // we want to "clear" the stroke
        ctx.setStrokeColor(UIColor.clear.cgColor);
        // any color will work, as the mask uses the alpha value
        ctx.setFillColor(UIColor.white.cgColor)
        ctx.setLineWidth(self.lineWidth)
        ctx.setLineCap(.round)
        ctx.setLineJoin(.round)
        if let pth = self.myPath {
            ctx.addPath(pth)
        }
        ctx.setBlendMode(.sourceIn)
        ctx.drawPath(using: .fillStroke)

    }
    
}

Here's a complete example... we'll create a UIView subclass and, since the effect will be "scratching off the image" we'll call it ScratchOffImageView:

class ScratchOffImageView: UIView {

    public var image: UIImage? {
        didSet {
            self.scratchOffImageLayer.contents = image?.cgImage
        }
    }

    // adjust drawing-line-width as desired
    //  or set from
    public var lineWidth: CGFloat = 24.0 {
        didSet {
            maskLayer.lineWidth = lineWidth
        }
    }

    private class MyCustomLayer: CALayer {
        
        var myPath: CGPath?
        var lineWidth: CGFloat = 24.0

        override func draw(in ctx: CGContext) {

            // fill entire layer with solid color
            ctx.setFillColor(UIColor.gray.cgColor)
            ctx.fill(self.bounds);

            // we want to "clear" the stroke
            ctx.setStrokeColor(UIColor.clear.cgColor);
            // any color will work, as the mask uses the alpha value
            ctx.setFillColor(UIColor.white.cgColor)
            ctx.setLineWidth(self.lineWidth)
            ctx.setLineCap(.round)
            ctx.setLineJoin(.round)
            if let pth = self.myPath {
                ctx.addPath(pth)
            }
            ctx.setBlendMode(.sourceIn)
            ctx.drawPath(using: .fillStroke)

        }
        
    }

    private let maskPath: UIBezierPath = UIBezierPath()
    private let maskLayer: MyCustomLayer = MyCustomLayer()
    private let scratchOffImageLayer: CALayer = CALayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {

        // Important, otherwise you will get a black rectangle
        maskLayer.isOpaque = false
        
        // add the image layer
        layer.addSublayer(scratchOffImageLayer)
        // assign the layer mask
        scratchOffImageLayer.mask = maskLayer
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        // set frames for mask and image layers
        maskLayer.frame = bounds
        scratchOffImageLayer.frame = bounds

        // triggers drawInContext
        maskLayer.setNeedsDisplay()
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let currentPoint = touch.location(in: self)
        maskPath.move(to: currentPoint)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let currentPoint = touch.location(in: self)
        // add line to our maskPath
        maskPath.addLine(to: currentPoint)
        // update the mask layer path
        maskLayer.myPath = maskPath.cgPath
        // triggers drawInContext
        maskLayer.setNeedsDisplay()
    }
    
}

and, an example view controller:

class ScratchOffViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        guard let img = UIImage(named: "test.jpg") else {
            fatalError("Could not load image!!!!")
        }

        let scratchOffView = ScratchOffImageView()
        
        // set the "scratch-off" image
        scratchOffView.image = img
        
        // default line width is 24.0
        //  we can set it to a different width here
        //scratchOffView.lineWidth = 12

        // let's add a light-gray label with red text
        //  we'll overlay the scratch-off-view on top of the label
        //  so we can see the text "through" the image
        let backgroundLabel = UILabel()
        backgroundLabel.font = .italicSystemFont(ofSize: 36)
        backgroundLabel.text = "This is some text in a label so we can see that the path is clear -- so it appears as if the image is being \"scratched off\""
        backgroundLabel.numberOfLines = 0
        backgroundLabel.textColor = .red
        backgroundLabel.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
        
        [backgroundLabel, scratchOffView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            backgroundLabel.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.7),
            backgroundLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            backgroundLabel.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            
            scratchOffView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
            scratchOffView.heightAnchor.constraint(equalTo: scratchOffView.widthAnchor, multiplier: 2.0 / 3.0),
            scratchOffView.centerXAnchor.constraint(equalTo: backgroundLabel.centerXAnchor),
            scratchOffView.centerYAnchor.constraint(equalTo: backgroundLabel.centerYAnchor),
        ])
        
    }

}

It will look like this to start - I used a 3:2 image, and overlaid it on a light-gray label with red text so we can see that we are "scratching off" the image:

enter image description here

then, after a little bit of "scratching":

enter image description here

and after a lot of "scratching":

enter image description here

  • Related