2

This question was challenging to word, but explaining the situation further should help.

Using the code below, I'm essentially masking a circle on the screen wherever I tap to reveal what's underneath the black UIView. When I tap, I record the CGPoint in an array to keep track of the tapped locations. For every subsequent tap I make, I remove the black UIView and recreate each tapped point from the array of CGPoints I'm tracking in order to create a new mask that includes all the previous points.

The result is something like this:

Tapped regions

I'm sure you can already spot what I'm asking about... How can I avoid the mask inverting wherever the circles intersect? Thanks for your help!

Here's my code for reference:

class MiniGameShadOViewController: UIViewController {
    
    //MARK: - DECLARATIONS
 
    var revealRadius : CGFloat = 50
    var tappedAreas : [CGPoint] = []

    //Objects
    @IBOutlet var shadedRegion: UIView!
    
    //Gesture Recognizers
    @IBOutlet var tapToReveal: UITapGestureRecognizer!
    
    

    //MARK: - VIEW STATES

    override func viewDidLoad() {
        super.viewDidLoad()

    }
    
  
    //MARK: - USER INTERACTIONS
    @IBAction func regionTapped(_ sender: UITapGestureRecognizer) {
        
        let tappedPoint = sender.location(in: view)
        
        tappedAreas.append(tappedPoint) //Hold a list of all previously tapped points
       
        //Clean up old overlays before adding the new one
        for subview in shadedRegion.subviews {
            if subview.accessibilityIdentifier != "Number" {subview.removeFromSuperview()}
        }
            //shadedRegion.layer.mask?.removeFromSuperlayer()
        
        createOverlay()
    }

    
    //MARK: - FUNCTIONS
    
    func createOverlay(){
        
        //Create the shroud that covers the orbs on the screen
        let overlayView = UIView(frame: shadedRegion.bounds)
            overlayView.alpha = 1
            overlayView.backgroundColor = UIColor.black
            overlayView.isUserInteractionEnabled = false
        shadedRegion.addSubview(overlayView)
        
        let path = CGMutablePath()
        
        //Create the box that represents the inverse/negative area relative to the circles
        path.addRect(CGRect(origin: .zero, size: overlayView.frame.size))

        //For each point tapped so far, create a circle there
        for point in tappedAreas {
            path.addArc(center: point, radius: revealRadius, startAngle: 0.0, endAngle: 2.0 * .pi, clockwise: false)
            path.closeSubpath() //This is required to prevent all circles from being joined together with lines
          }
        
            //Fill each of my circles
            let maskLayer = CAShapeLayer()
                maskLayer.backgroundColor = UIColor.black.cgColor
                maskLayer.path = path;
                maskLayer.fillRule = .evenOdd
            
            //Cut out the circles inside that box
            overlayView.layer.mask = maskLayer
            overlayView.clipsToBounds = true
        }
}
Justin
  • 319
  • 3
  • 13

1 Answers1

4

You asked:

how can I avoid inverting the mask when masked regions intersect?

In short, do not use the .evenOdd fill rule.


You have specified a fillRule of .evenOdd. That results in intersections of paths to invert. Here is a red colored view with a mask consisting of a path with two overlapping circular arcs with the .evenOdd rule:

enter image description here

If you use .nonZero (which, coincidentally, is the default fill rule for shape layers), they will not invert each other:

enter image description here


E.g.

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    
    var maskLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.fillColor = UIColor.white.cgColor
        return shapeLayer
    }()

    var points: [CGPoint] = [] // this isn't strictly necessary, but just in case you want an array of the points that were tapped
    var path = UIBezierPath()

    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.layer.mask = maskLayer
    }

    @IBAction func handleTapGesture(_ gesture: UITapGestureRecognizer) {
        let point = gesture.location(in: gesture.view)
        points.append(point)

        path.move(to: point)
        path.addArc(withCenter: point, radius: 40, startAngle: 0, endAngle: .pi * 2, clockwise: true)

        maskLayer.path = path.cgPath
    }
}

Resulting in:

enter image description here

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thanks! This has gotten me closer. I had to add `path.close()` after `path.addArc()` but they are definitely merging now. However I did lose the whole 'cutting holes in a black screen' aspect going this route. It's now not adding the black screen at all and is instead adding circles that are coloured whatever I set the `overlayView.backgroundColor` too. Ultimately I'd like the screen to be black and to cut transparent holes in it at the tapped points in order to see the view behind it. I'm continuing to fool around with it though so I'll hopefully stumble on it :) – Justin Jun 26 '21 at 18:07
  • Ugh I'm still unable to get it. It appears that the new view I'm creating to mask is starting off as invisible and my circles I'm creating are making those areas visible, but I need the reverse of that. Starting off with the view being visible, and my circles making it invisible. – Justin Jun 26 '21 at 19:09
  • If you want to reveal circles of your imageview, put the imageview _above_ the black “overlay” view and mask the whole image view. Then on user taps, replace the image view’s mask with a `CAShapeLayer` with these arcs, revealing that portion of the image view which is in front of the black view. But from the image you’ve shared in your question, I assumed you were already doing that… – Rob Jun 26 '21 at 20:59
  • Wow this is fantastic, and way less code than what I was ending up with too. Works beautifully! The reason it looked correct in my image is from using the `.evenodd` fill. It gave the same result I wanted except for the intersecting circles issue, which your solution perfectly fixes and didn't even require the extra hoops I was previously trying to jump through haha. Thanks! – Justin Jun 27 '21 at 00:14
  • @Rob could you please update the solution so it reveals the content inside of the path? – Leo Feb 27 '23 at 22:27
  • I’m unclear what your question is, because that’s what the above is doing. It reveals the portion as dictated by the path used by the shapelayer mask. In the above example, the path is simply a series of circles, but you can use whatever path you want. – Rob Feb 28 '23 at 22:13