'SwiftUI add inverted mask

I'm trying to add a mask to two shapes such that the second shape masks out the first shape. If I do something like Circle().mask(Circle().offset(…)), this has the opposite affect: preventing anything outside the first circle from being visible.

For UIView the answer is here: iOS invert mask in drawRect

However, trying to implement this in SwiftUI without UIView eludes me. I tried implementing an InvertedShape with I could then use as a mask:

struct InvertedShape<OriginalType: Shape>: Shape {
    let originalShape: OriginalType

    func path(in rect: CGRect) -> Path {
        let mutableOriginal = originalShape.path(in: rect).cgPath.mutableCopy()!
        mutableOriginal.addPath(Path(rect).cgPath)
        return Path(mutableOriginal)
            .evenOddFillRule()
    }
}

Unfortunately, SwiftUI paths do not have addPath(Path) (because they are immutable) or evenOddFillRule(). You can access the path's CGPath and make a mutable copy and then add the two paths, however, evenOddFillRule needs to be set on the CGLayer, not the CGPath. So unless I can get to the CGLayer, I'm unsure how to proceed.

This is Swift 5.



Solution 1:[1]

Here is a demo of possible approach of creating inverted mask, by SwiftUI only, (on example to make a hole in view)

SwiftUI hole mask, reverse mask

func HoleShapeMask(in rect: CGRect) -> Path {
    var shape = Rectangle().path(in: rect)
    shape.addPath(Circle().path(in: rect))
    return shape
}

struct TestInvertedMask: View {

    let rect = CGRect(x: 0, y: 0, width: 300, height: 100)
    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: rect.width, height: rect.height)
            .mask(HoleShapeMask(in: rect).fill(style: FillStyle(eoFill: true)))
    }
}

backup

Solution 2:[2]

Here's another way to do it, which is more Swiftly.

The trick is to use:

YourMaskView()
   .compositingGroup()
   .luminanceToAlpha() 

maskedView.mask(YourMaskView())

Just create your mask with Black and White shapes, black will be transparent, white opaque, anything in between is going to be semi-transparent.

.compositingView(), similar to .drawingGroup(), rasterises the view (converts it to a bitmap texture). By the way, this also happens when you .blur or do any other pixel-level operations.

.luminanceToAlpha() takes the RGB luminance levels (I guess by averaging the RGB values), and maps them to the Alpha (opacity) channel of the bitmap.

Solution 3:[3]

Using a mask such as in the accepted answer is a good approach. Unfortunately, masks do not affect hit testing. Making a shape with a hole can be done in the following way.

extension Path {
    var reversed: Path {
        let reversedCGPath = UIBezierPath(cgPath: cgPath)
            .reversing()
            .cgPath
        return Path(reversedCGPath)
    }
}

struct ShapeWithHole: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Rectangle().path(in: rect)
        let hole = Circle().path(in: rect).reversed
        path.addPath(hole)
        return path
    }
}

The trick is to reverse the path for the hole. Unfortunately Path does not (yet) support reversing the path out-of-the-box, hence the extension (which uses UIBezierPath). The shape can then be used for clipping and hit-testing purposes:

struct MaskedView: View {

    var body: some View {
        Rectangle()
            .fill(Color.blue)
            .frame(width: 300, height: 100)
            .clipShape(ShapeWithHole())    // clips or masks the view
            .contentShape(ShapeWithHole()) // needed for hit-testing
    }
}

Solution 4:[4]

Based on this article, here's a .reverseMask modifier you can use instead of .mask. I modified it to support iOS 13 and up.

extension View {
    @inlinable func reverseMask<Mask: View>(
        alignment: Alignment = .center,
        @ViewBuilder _ mask: () -> Mask
    ) -> some View {
            self.mask(
                ZStack {
                    Rectangle()

                    mask()
                        .blendMode(.destinationOut)
                }
            )
        }
}

Usage:

ViewToMask()
.reverseMask {
    MaskView()
}

Solution 5:[5]

I haven't tested this yet, but could you do something like this:

extension UIView {
    func mask(_ rect: CGRect, invert: Bool = false) {
        let maskLayer = CAShapeLayer()
        let path = CGMutablePath()

        if (invert) {
            path.addRect(bounds)
        }
        path.addRect(rect)
        maskLayer.path = path

        if (invert) {
            maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
        }

        // Set the mask of the view.
        layer.mask = maskLayer
    }
}

struct MaskView: UIViewRepresentable {
    @Binding var child: UIHostingController<ImageView>
    @Binding var rect: CGRect
    @Binding var invert: Bool

    func makeUIView(context: UIViewRepresentableContext<MaskView>) -> UIView {
        let view = UIView()

        self.child.view.mask(self.rect, invert: self.invert)

        view.addSubview(self.child.view)

        return view
    }

    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<MaskView>) {

    }
}

Usage:

struct ImageView: View {
    var body: some View {
        Image("image1")
    }
}

struct ContentView: View {
    @State var child = UIHostingController(rootView: ImageView())
    @State var rect: CGRect = CGRect(x: 50, y: 50, width: 50, height: 50)
    @State var invert: Bool = false

    var body: some View {
        VStack(alignment: .leading) {
            MaskView(child: self.$child, rect: self.$rect, invert: self.$invert)
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 neoneye
Solution 3
Solution 4
Solution 5