0

Background

I’m trying to create a mask using two UIView with the below code.

Masking works, but not in the way that I was intending, images below.

I’m trying to get masking to work like Image A, but I’m getting the masking in Image B.

Question

How do I create a mask like Image A with two UIView?

Code

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let redView = UIView(frame: CGRect(x: 100, y: 100, width: 200, height: 200))
        redView.backgroundColor = .red
        view.addSubview(redView)
        
        let maskView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
        maskView.backgroundColor = .blue
        maskView.layer.cornerRadius = 100
        redView.mask = maskView
        
    }

}

Images enter image description here

user4806509
  • 2,925
  • 5
  • 37
  • 72
  • 1
    The short answer to "How do I create a mask like Image A with two UIViews?" is "You don't. You mask layers, not views." See my answer for more info. – Duncan C Apr 21 '23 at 17:50

3 Answers3

1

Masking is something provided by the lower level Core Animation layers that back UIViews. You need to provide a LAYER to serve as a mask, not a view.

That layer can either be a CGImage, in which case the alpha channel of the image is what determines the masking, or you can use a CAShapeLayer.

For a circular shape like in your case a shape layer is likely the better choice.

Edit:

Check out the code in this answer:

https://stackoverflow.com/a/22019632/205185

Here's the (very old Objective-C) code from that answer:

CAShapeLayer *shape = [CAShapeLayer layer];

shape.frame = self.bounds;

CGMutablePathRef pathRef = CGPathCreateMutable();
CGPathAddRect(pathRef, NULL, self.bounds);
CGPathAddEllipseInRect(pathRef, NULL, self.bounds);

shape.fillRule = kCAFillRuleEvenOdd;
shape.path = pathRef;

self.layer.mask = shape;

The Swift code would look something like this:

let shape = CAShapeLayer()
shape.frame = self.bounds
var path = CGPath()
path.addRect(self.bounds)
path.addEllipse(in: self.bounds)

redView.mask = shape

(I spent about a minute on that code. It likely contains a couple of syntax errors.)

Also note that if your app supports resizing, you'll need to update your mask path if the owning view changes size (during device rotation, for example.)

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • Thank you. I'm not sure how to do that. I am planning to also animate the mask, reduce its width, which is the reason I have used a UIView. – user4806509 Apr 21 '23 at 16:09
  • Core Animation supports its own type of animation, but it is harder to use than UIView animation. – Duncan C Apr 21 '23 at 16:20
  • See the link I added in an edit to my answer. It looks like that answer provides the same "punch a hole in the middle" masking that you are after. – Duncan C Apr 21 '23 at 17:22
  • When it comes time to animate your mask, post a new question and one of us who's experienced with CAAnimation should be able to help you. – Duncan C Apr 21 '23 at 17:22
  • See the edit to my answer for some sample code to get you started with shape layers as masks. – Duncan C Apr 21 '23 at 17:49
  • Thanks, Duncan. I'll give this a go! Appreciate your help. – user4806509 Apr 22 '23 at 17:00
1

The key observation is that a mask will reveal the underlying view/layer based upon the alpha channel of the mask. As the documentation says:

The view’s alpha channel determines how much of the view’s content and background shows through. Fully or partially opaque pixels allow the underlying content to show through but fully transparent pixels block that content.

Thus, a mask yields your scenario “B”.

You ask:

How do I create a mask like Image A with two UIView?

In short, this sort of inverted masking is not something you can just do with view’s and applying a corner radius of one of their layers. We would generally achieve that sort of inverse mask by masking the view’s layer with a CAShapeLayer, where you can draw whatever shape you want.

let origin = CGPoint(x: 100, y: 100)
let size = CGSize(width: 200, height: 300)
let cornerRadius: CGFloat = 100

let blueView = UIView(frame: CGRect(origin: origin, size: size))
blueView.backgroundColor = .blue
view.addSubview(blueView)

let rect = CGRect(origin: .zero, size: size)
let path = UIBezierPath(rect: rect)
path.append(UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius))

let maskLayer = CAShapeLayer()
maskLayer.fillRule = .evenOdd
maskLayer.path = path.cgPath

blueView.layer.mask = maskLayer

But, frankly, a UIViewController probably shouldn’t be reaching into a UIView and adjusting its layer’s mask. I would define a CornersView:

class CornersView: UIView {
    var cornerRadius: CGFloat? { didSet { setNeedsLayout() } }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        let radius = cornerRadius ?? min(bounds.width, bounds.height) / 2
        let path = UIBezierPath(rect: bounds)
        path.append(UIBezierPath(roundedRect: bounds, cornerRadius: radius))
        
        let maskLayer = CAShapeLayer()
        maskLayer.fillRule = .evenOdd
        maskLayer.path = path.cgPath
        layer.mask = maskLayer
    }
}

By putting the rounding logic in layoutSubviews, that means that this rounding will be applied automatically regardless of the size. Then, you would use it like so:

let blueView = CornersView(frame: CGRect(x: 100, y: 100, width: 200, height: 200))
blueView.backgroundColor = .blue
view.addSubview(blueView)

If you use storyboards, you might even make CornersView an @IBDesignable and make cornerRadius an @IBInspectable.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
0

Another way to do this is to make use of the view layer border property.

By adding a view maskShape that is double the size of a container view maskView, we can set the view backgroundColor to transparent and adjust the view borderColor, borderWidth, and cornerRadius to create various basic shape masks, which can also be animated in various ways.

Code

import UIKit

class ViewController: UIViewController {
    
    // Set status bar hidden
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.setNeedsStatusBarAppearanceUpdate()
    }
    override var prefersStatusBarHidden : Bool {
        return true
    }
    override var childForStatusBarHidden : UIViewController? {
        return nil
    }

    // Prepare view
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set view background gradient
        let gradientLayer = CAGradientLayer()
        gradientLayer.colors = [UIColor.gray.cgColor, UIColor.white.cgColor, UIColor.gray.cgColor, UIColor.white.cgColor, UIColor.gray.cgColor]
        gradientLayer.locations = [0, 0.1, 0.2, 0.3, 1]
        gradientLayer.frame = view.bounds
        view.layer.insertSublayer(gradientLayer, at: 0)
        
        // Set mask view
        let maskView = UIView(frame: CGRect(x: 100, y: 100, width: 200, height: 200))
        maskView.backgroundColor = UIColor.clear
        maskView.layer.masksToBounds = true
        view.addSubview(maskView)
        
        // Set mask shape
        let maskShape = UIView(frame: CGRect(x: -100, y: -100, width: 400, height: 400))
        maskShape.backgroundColor = UIColor.clear
        maskShape.layer.borderColor = UIColor.blue.cgColor
        maskShape.layer.borderWidth = 100
        maskShape.layer.cornerRadius = 200
        maskView.addSubview(maskShape)
        
        // Animate mask shape
        UIView.animate(withDuration: 5, delay: 0, options: [.curveEaseInOut, .repeat], animations: {
            maskShape.layer.borderWidth = 150
            }, completion: nil)

    }

}

Image

Simulator screenshot of a square view with an animated circle mask.

user4806509
  • 2,925
  • 5
  • 37
  • 72