Search code examples
swiftmacosswiftuicgpath

Reverse mask with multiple overlapping rectangles


I have a rectangle that I'm trying to do a reverse mask on, removing a variable number of other rectangles from the original shape.

If I were just trying to remove one rectangle, this would be easy, using an evenOdd fill. However, once I have multiple rectangles, the intersections of those rectangles become filled again. See the below example:

enter image description here

In the above example, I ideally, I wouldn't see the small red squares, which are the intersections of the 3 shapes overlayed onto the red background. In other words, the ideal is the inverse of this:

enter image description here

Is there any way I can eliminate the extra crossings in my Path so that the entire area "covered" by my Shape will get reverse-masked out?

Reproducible code to get to the first image:

struct MyShape: Shape {
    func path(in rect: CGRect) -> Path {
        var p = Path()
        
        p.addRect(rect)
        
        p.addRect(.init(origin: .init(x: 60, y: 60),
                        size: .init(width: 100, height: 100)))

        p.addRect(.init(origin: .init(x: 120, y: 120),
                        size: .init(width: 100, height: 100)))
        
        p.addRect(.init(origin: .zero,
                        size: .init(width: 100, height: 100)))
        
        return p
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            Color.red
                .mask(
                    MyShape()
                        .fill(style: .init(eoFill: true))
                )
        }
        .frame(width: 400, height: 300)
    }
}

Note: I'm on macOS, but have a deployment target of 11.3, so a solution using CGPath's intersection won't work.

Also worth noting that in my basic example, I could hard code the path by hand, but assume that the overlayed rectangles are coming in dynamically, so hardcoding a path also isn't workable.


Solution

  • Rather than use your shape directly as a mask, you can create a "reverse mask" using .blendMode(.destinationOut) to "cut out" a shape from a Rectangle, then the result is used as the mask:

    struct MyShape: Shape {
        func path(in rect: CGRect) -> Path {
            var p = Path()
            p.addRect(CGRect(x: 60, y: 60, width: 100, height: 100))
            p.addRect(CGRect(x: 120, y: 120, width: 100, height: 100))
            p.addRect(CGRect(x: 0, y: 0, width: 100, height: 100))
            return p
        }
    }
    
    struct ContentView: View {
        var body: some View {
            ZStack {
                Color.yellow
                ZStack {
                    Color.red.mask {
                        // Mask is Rectangle with MyShape removed
                        Rectangle()
                            .overlay() {
                                MyShape()
                                    .blendMode(.destinationOut)
                            }
                    }
                    .frame(width: 400, height: 300)
                }
            }
        }
    }
    

    enter image description here

    A full (and excellent) explanation as to how this all works can be found here:

    https://www.fivestars.blog/articles/reverse-masks-how-to/