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:
then, after a little bit of "scratching":
and after a lot of "scratching":