Joseph Van Boxtel

Cutout Shape in SwiftUI

Recently I was tasked with writing a barcode scanner screen at my day job. The specific design had a rounded rectangle clipping on the transparent overaly. That seems simple until I realized that the rounded rect was cutout not filled. Turns out the solution is relatively straightforward: use even-odd fill.

Imagine two shapes, a rectangle that represents the full size overlay that would cover the entire screen, and a rounded rectangle that is centered on the rectangle. All I thought I needed to do was use the clipShape API, but there is more to it than that.

Color.blue.opacity(0.5)
    .cutout(RoundedRectangle(cornerRadius: 16).scale(0.5))

This yields the following result:

clipShape without even-odd fill

A little bit of googling suggests using the even-odd fill mode. That too comes a little short without fully understanding how it works. The naive addition of just adding even-odd fill looks like this.

Color.blue.opacity(0.5)
    .clipShape(RoundedRectangle(cornerRadius: 16).scale(0.5), style: FillStyle(eoFill: true))
clipShape with even-odd fill

After studying the examples more closely, I realized the problem: even-odd fill only affects paths with strokes that overlap. My example doesn't have any overlap so there is no difference. The solution then became straightforward; add a rectangle before adding my rounded rect so the rounded rect will all be overlap and thus used for the clipping.

struct CutoutRoundedRectangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.addPath(Rectangle().path(in: rect))
            path.addPath(RoundedRectangle(cornerRadius: 16).scale(0.5).path(in: rect))
        }
    }
}

Now this shape cuts out the area I need:

Color.blue.opacity(0.5)
    .clipShape(CutoutRoundedRectangle(), style: FillStyle(eoFill: true))
clipShape with even-odd fill and stacked rectangles

That solution works just fine, but I really enjoy generalizing these kind of solutions so that the code is clear and potentially reusable in the future.

CutoutRoundedRectanglecan be nicely generalized to accept both shapes for arbirary shape stacking.

extension View {
    func cutout<S: Shape>(_ shape: S) -> some View {
        self.clipShape(StackedShape(bottom: Rectangle(), top: shape), style: FillStyle(eoFill: true))
    }
}

struct StackedShape<Bottom: Shape, Top: Shape>: Shape {
    var bottom: Bottom
    var top: Top
    
    func path(in rect: CGRect) -> Path {
        return Path { path in
            path.addPath(bottom.path(in: rect))
            path.addPath(top.path(in: rect))
        }
    }
}

Improving the call site to this:

Color.blue.opacity(0.5)
    .cutout(RoundedRectangle(cornerRadius: 16).scale(0.5))
Tagged with: