SwiftUI add inverted mask
Asked Answered
C

7

37

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.

Clasp answered 9/1, 2020 at 1:44 Comment(6)
SwiftUI paths do have addPath(Path), as well as even-odd fill rule, see below my answer.Whipstall
@Whipstall I could have sworn I checked for addPath, I guess I had an immutable copy for some reason...Clasp
@Whipstall it looks like Paths don't have fill rules, but Shapes do.Clasp
In SwiftUI Path 'is a' Shape.Whipstall
@Whipstall 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.Clasp
A similar question has been answered here: - Inverted mask swiftui with system imageBusiness
W
42

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)))
    }
}
Whipstall answered 9/1, 2020 at 8:20 Comment(1)
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?Paynim
C
33

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.

Crescen answered 5/5, 2020 at 10:36 Comment(3)
this works great when YourMaskView is black & white, thanksHernandes
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.Merriweather
My mask view consists of drawing a path. It is possible it has built-in alpha.Merriweather
D
26

Use .blendMode modifier

ZStack {
      Rectangle() // destination
      Circle()    // source
        .blendMode(.destinationOut)
    }
    .compositingGroup()
Danettedaney answered 12/8, 2022 at 8:11 Comment(4)
As this is porter-duff blending, is it different from other options?Clasp
@GarrettMotzner this is Apple's native modifier which has been available since the first version of SwiftUI (ios 13). See the documentation, there are a lot of options for using this modifier.Danettedaney
definitely the most simple and elegant way to do that.Yod
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.Hunan
E
13

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()
}
Etiology answered 23/2, 2022 at 14:36 Comment(1)
You may need to add .compositingGroup() after the ZStack.Ruffina
R
12

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
    }
}
Raucous answered 26/6, 2020 at 7:25 Comment(3)
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).Raucous
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)Clasp
Masking is fine and you can still combine both methods if you need hit testing to respect the shape.Raucous
L
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)
        }
    }
}
Lunate answered 9/1, 2020 at 4:22 Comment(0)
D
2

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
    }
}
Ducharme answered 10/2, 2023 at 8:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.