Search code examples
iosswiftanimationuikit

Swift ios cut out rounded rect from view allowing colour changes


I'm using this approach to cut out a rounded rect "window" from a background view:

override func draw(_ rect: CGRect) {
        guard let rectsArray = rectsArray else {
            return
        }

        for holeRect in rectsArray {
            let holeRectIntersection = rect.intersection(holeRect)

            if let context = UIGraphicsGetCurrentContext() {
                let roundedWindow = UIBezierPath(roundedRect: holeRect, cornerRadius: 15.0)
                if holeRectIntersection.intersects(rect) {
                    context.addPath(roundedWindow.cgPath)
                    context.clip()
                    context.clear(holeRectIntersection)
                    context.setFillColor(UIColor.clear.cgColor)
                    context.fill(holeRectIntersection)
                }
            }

        }
    }

In layoutSubviews() I update the background colour add my "window frame" rect:

override func layoutSubviews() {
        super.layoutSubviews()
        backgroundColor = self.baseMoodColour
        isOpaque = false
        self.rectsArray?.removeAll()
        self.rectsArray = [dragAreaView.frame]
}

I'm adding the rect here because layoutSubviews() updates the size of the "window frame" (i.e., the rect changes after layoutSubviews() runs).

The basic mechanism works as expected, however, if I change the background colour, the cutout window fills with black. So I'm wondering how I can animate a background colour change with this kind of setup? That is, I want to animate the colour of the area outside the cutout window (the window remains clear).

I've tried updating backgroundColor directly, and also using didSet in the accessor of a custom colour variable in my UIView subclass, but both cause the same filling-in of the "window".

    var baseMoodColour: UIColor {
        didSet {
            self.backgroundColor = baseMoodColour
            self.setNeedsDisplay()
        }
    }

Solution

  • Answering my own question, based on @matt's suggestion (and linked example), I did it with a CAShapeLayer. There was an extra "hitch" in my requirements, since I have a couple of views on top of the one I needed to mask out. So, I did the masking like this:

    func cutOutWindow() {
            // maskedBackgroundView is an additional view, inserted ONLY for the mask
            let r = self.maskedBackgroundView.bounds
            // Adjust frame for dragAreaView's border
            var dragSize = self.dragAreaView.frame.size
            var dragPosition = self.dragAreaView.frame.origin
            dragSize.width -= 6.0
            dragSize.height -= 6.0
            dragPosition.x += 3.0
            dragPosition.y += 3.0
            let r2 = CGRect(x: dragPosition.x, y: dragPosition.y, width: dragSize.width, height: dragSize.height)
            let roundedWindow = UIBezierPath(roundedRect: r2, cornerRadius: 15.0)
            let mask = CAShapeLayer()
            let path = CGMutablePath()
            path.addPath(roundedWindow.cgPath)
            path.addRect(r)
            mask.path = path
            mask.fillRule = kCAFillRuleEvenOdd
            self.maskedBackgroundView.layer.mask = mask
        }
    

    Then I had to apply the colour change to maskedBackgroundView.layer.backgroundColor (i.e., to the layer, not the view). With that in place, I get the cutout I need, with animatable colour changes. Thanks @matt for pointing me in the right direction.