Making a crater in UIView with CALayer

Making a crater in UIView with CALayer

Although title sounds horrible, believe me - It's not that bad. This is a trick I learned from StackOverflow post. Although seemingly innocuous, it took me a while to figure out given the view, how to puncture any shape into it.

To make it more interesting let's not only add boring shapes, but we will spice up with few more shapes for entertainment. Beware though, you won't be dealing only with UIViews, you will fight with CALayer as well which makes it more interesting and frightening as well. (For me personally though, I cannot write a CAShapeLayer code without referring to StackOverflow. Syntax just gets me every time)

So...here we go!

First off, let's make a view to make a hole into. For simplicity we will call it simply a view. Let's build our view. For a reference, we will color it with Blue background. Background will be colored in the Red

Before we begin, please note that this tutorial will only apply on views sized and positioned with frames. If you are using autolayouts, the frame size won't be immediately available and you potentially have to put your code inside viewDidLayoutSubview method which gets called as soon as autolayout is finished laying out views on the screen


self.view.backgroundColor = .red
let view = UIView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height))
view.backgroundColor = .blue
self.view.addSubview(view)

Next step, let's create a shape mask layer, say circle using UIBezierPath


let maskLayer = CAShapeLayer()
maskLayer.frame = view.bounds
// Create the frame for the circle.
let radius: CGFloat = 50.0
// Rectangle in which circle will be drawn
let rect = CGRect(x: 100, y: 100, width: 2 * radius, height: 2 * radius)
let circlePath = UIBezierPath(ovalIn: rect)
// Create a path
let path = UIBezierPath(rect: view.bounds)
// Append additional path which will create a circle
path.append(circlePath)
// Setup the fill rule to EvenOdd to properly mask the specified area and make a crater
maskLayer.fillRule = kCAFillRuleEvenOdd
// Append the circle to the path so that it is subtracted.
maskLayer.path = path.cgPath
// Mask our view with Blue background so that portion of red background is visible
view.layer.mask = maskLayer

The result will look like this,

circle_crater

To add some fun, let add few more shapes. All you have to do is to replace a circlePath with any path you want.

Triangle


let trianglePath = UIBezierPath()
trianglePath.move(to: CGPoint(x: view.frame.size.width/2, y: 200))
trianglePath.addLine(to: CGPoint(x:100, y: 300))
trianglePath.addLine(to: CGPoint(x: view.frame.size.width - 100, y: 300))
trianglePath.close()
let path = UIBezierPath(rect: view.bounds)
// Append additional path which will create a circle
path.append(trianglePath)

Triangular crater
triangular_create

Possibilities are endless. Polygons, star, random shape, square etc. etc.

To make it more generic, let's create a function where you can simply pass any shape enum you want and it will pick it up from there.


enum ShapeType {
    case Rectangle
    case Circle
    case Triangle
    case Pentagon
    case All
}

// A function which will pick a mask to apply based on the enum thus passed. Remember that All will imply to apply all masks
func maskLayerWith(inputView:UIView, shape: ShapeType) {
    let finalPath = UIBezierPath(rect: inputView.bounds)
    let maskLayer = CAShapeLayer()
    maskLayer.frame = inputView.bounds

    if (shape == .Rectangle) {
        finalPath.append(self.rectanglePath())
    } else if (shape == .Circle) {
        finalPath.append(self.circlePath())
    } else if (shape == .Triangle) {
        finalPath.append(self.trianglePath())
    } else if (shape == .Pentagon) {
        finalPath.append(self.pentagonPath())
    } else if (shape == .All) {
        finalPath.append(self.rectanglePath())
        finalPath.append(self.circlePath())
        finalPath.append(self.trianglePath())
        finalPath.append(self.pentagonPath())
    }
    maskLayer.fillRule = kCAFillRuleEvenOdd
    maskLayer.path = finalPath.cgPath
    inputView.layer.mask = maskLayer
}

func rectanglePath() -> UIBezierPath {
    let rect = CGRect(x: 250, y: 50, width: 100, height: 100)
    return UIBezierPath(rect: rect)
}

func circlePath() -> UIBezierPath {
    let radius: CGFloat = 50.0
    let rect = CGRect(x:100, y: 300, width: 2 * radius, height: 2 * radius)
    return UIBezierPath(ovalIn: rect)
}

func trianglePath() -> UIBezierPath {
    let trianglePath = UIBezierPath()
    trianglePath.move(to: CGPoint(x: view.frame.size.width/2, y: 400))
    trianglePath.addLine(to: CGPoint(x:100, y: 500))
    trianglePath.addLine(to: CGPoint(x: view.frame.size.width - 100, y: 500))
    trianglePath.close()
    return trianglePath
}

func pentagonPath() -> UIBezierPath {
    // Ref: https://developer.apple.com/library/content/documentation/2DDrawing/Conceptual/DrawingPrintingiOS/BezierPaths/BezierPaths.html
    let pentagonPath = UIBezierPath()
    // Set the starting point of the shape.
    pentagonPath.move(to: CGPoint(x: 100, y: 70))
    pentagonPath.addLine(to: CGPoint(x: 200, y: 110))
    pentagonPath.addLine(to: CGPoint(x: 160, y: 210))
    pentagonPath.addLine(to: CGPoint(x: 40, y: 210))
    pentagonPath.addLine(to: CGPoint(x: 0, y: 110))
    pentagonPath.close()
    return pentagonPath
}

If you want to have little fun, you can pass All to the method which applies mask to the given UIView

self.maskLayerWith(inputView: view, shape: .All)

Result with multiple craters
multiple_craters

The code is hosted on the GitHub repo. If you want to fiddle with it, you can. Any questions, comments, concerns, Welcome.

You can extend the repo further by adding any fun shapes. Thing to note down is that func maskLayerWith(inputView:UIView, shape: ShapeType) is a generic method. You can apply crater to any inputView with any shape you want by extending the present code