31

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.

Garrett Motzner
  • 3,021
  • 1
  • 13
  • 30
  • 1
    SwiftUI paths _do_ have addPath(Path), as well as even-odd fill rule, see below my answer. – Asperi Jan 09 '20 at 18:11
  • @Asperi I could have sworn I checked for addPath, I guess I had an immutable copy for some reason... – Garrett Motzner Jan 09 '20 at 22:24
  • @Asperi it looks like _`Path`s_ don't have fill rules, but `Shape`s do. – Garrett Motzner Jan 10 '20 at 20:42
  • In SwiftUI Path 'is a' Shape. – Asperi Jan 10 '20 at 20:46
  • @Asperi true. The issue though is that `.fill` returns a view, not a path... So that was a problem with my implementation, because I kinda wanted to return a path or shape... But that was not really needed, I just thought it was. – Garrett Motzner Jan 10 '20 at 20:52
  • 1
    A similar question has been answered [here](https://stackoverflow.com/a/63934218/5409815): - [Inverted mask swiftui with system image](https://stackoverflow.com/a/63934218/5409815) – lochiwei Sep 17 '20 at 11:12

7 Answers7

38

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)))
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Sorry I know it isn't relevant but is it possible to add this shape to a ZStack and allow user to interact only through the Hole with the content in background? – Asadullah Ali Apr 14 '23 at 02:22
31

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.

neoneye
  • 50,398
  • 25
  • 166
  • 151
Vlad Lego
  • 1,670
  • 1
  • 18
  • 19
  • 2
    this works great when YourMaskView is black & white, thanks – Heestand XYZ Jun 12 '20 at 08:51
  • Maybe SwiftUI has updated, but I just attempted to follow this and `.compositingGroup()` and `.luminanceToAlpha()` is no longer needed. It was causing it to not work in my case. Instead, just ensuring my mask view is black and white, then calling `maskedView.mask(YourMaskView())` did the job. – Mathieson Dec 23 '21 at 00:08
  • My mask view consists of drawing a path. It is possible it has built-in alpha. – Mathieson Dec 23 '21 at 00:12
12

Use .blendMode modifier

ZStack {
      Rectangle() // destination
      Circle()    // source
        .blendMode(.destinationOut)
    }
    .compositingGroup()
Sergei Volkov
  • 818
  • 8
  • 15
  • As this is porter-duff blending, is it different from other options? – Garrett Motzner Aug 15 '22 at 15:35
  • @GarrettMotzner this is Apple's native modifier which has been available since the first version of SwiftUI (ios 13). See the [documentation](https://developer.apple.com/documentation/swiftui/view/blendmode(_:)), there are a lot of options for using this modifier. – Sergei Volkov Aug 16 '22 at 08:56
  • definitely the most simple and elegant way to do that. – Nicolas Manzini Jul 24 '23 at 10:12
  • Thanks for mentioning the `.compositionGroup()` modifier! I was wondering why my blended out shape was appearing black when I was moving my app in background or when I was sharing my phone's screen using QuickTime Player. Turns out I was just missing this modifier on the parent View. – Paul-Etienne Aug 10 '23 at 08:23
11

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
    }
}
Jack Goossen
  • 799
  • 4
  • 14
  • This will create the exclusion of the paths in a single Shape. It does not affect overlapping multiple shapes (though you can get the same visual effect with this method). – Jack Goossen Jun 26 '20 at 16:35
  • cool. for my use case though I think still need a mask because I need to mask a stroked shape, and the strokes need to fit the clip mask exactly. (don't have my code ATM to play around with technique, but I like the idea) – Garrett Motzner Jun 26 '20 at 16:51
  • Masking is fine and you can still combine both methods if you need hit testing to respect the shape. – Jack Goossen Jun 26 '20 at 17:28
8

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()
}
Tamás Sengel
  • 55,884
  • 29
  • 169
  • 223
2

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)
        }
    }
}
gotnull
  • 26,454
  • 22
  • 137
  • 203
1

As a refinement on @Asperi's answer: if you don't want to pass an explicit size to the shape mask, you can use SwiftUI's Shape type to achieve the same result:

struct ContentView: View {
    var body: some View {
        Rectangle()
            .fill(.black)
            .mask(HoleShape().fill(style: FillStyle(eoFill: true)))
    }
}

struct HoleShape: Shape {
    func path(in rect: CGRect) -> Path {
        var shape = Rectangle().path(in: rect)
        shape.addPath(Circle().path(in: rect))
        return shape
    }
}
Mathijs B
  • 73
  • 5