Search code examples
swiftuimaskinggeometryreader

SwiftUI Cut out hole for view in dark overlay for tutorial-like highlighting


I'm trying to create a tutorial framework in SwiftUI that finds a specific view and highlights it by darkening the rest of the screen.

For example: Say I have three circles... Three circles no mask

And I want to highlight the blue one... Highlighted blue circle

Here's what I have come up with so far.

  • Create a ZStack.
  • Place semi-transparent black background on top.
  • Add inverted mask to background to punch hole in it to reveal blue circle.

This works, but I need the size and location of the blue circle, in order to know where to place the mask.

In order to achieve this, I have to write some hacky code with GeometryReader. Ie: Create a geometry reader inside of the blue circle's overlay modifier and return a clear background. This allows me to retrieve the dynamic size and location of the view. If I just wrapped the blue circle in a normal GeometryReader statement it would remove the dynamic size and position of the view.

Lastly I store the frame of the blue circle, and set the frame and position of the mask using it, thus achieving what I want, a cutout over the top of the blue circle in the dark overlay.

All this being said I'm getting a runtime error "Modifying state during view update, this will cause undefined behavior."

Also approach seems very hack and sticky. Ideally I'd like to create a separate framework where I could target a view, then add an overlay view with a specific shape cut out in order to highlight the specific view.

Here's the code from the above example:

@State var blueFrame: CGRect = .zero

var body: some View {
    
    ZStack {
        VStack {
            Circle()
                .fill(Color.red)
                .frame(width: 100, height: 100)
            
            ZStack {
                Circle()
                    .fill(Color.blue)
                    .frame(width: 100, height: 100)
                    .overlay {
                        GeometryReader { geometry -> Color in
                            
                            let geoFrame = geometry.frame(in: .global)
                            blueFrame = CGRect(x: geoFrame.origin.x + (geoFrame.width / 2),
                                                  y: geoFrame.origin.y + (geoFrame.height / 2),
                                                  width: geoFrame.width,
                                                  height: geoFrame.height)
                            
                            return Color.clear
                        }
                    }
            }
            
            Circle()
                .fill(Color.green)
                .frame(width: 100, height: 100)
        }
        
        Color.black.opacity(0.75)
            .edgesIgnoringSafeArea(.all)
            .reverseMask {
                Circle()
                    .frame(width: blueFrame.width + 10, height: blueFrame.height + 10)
                    .position(blueFrame.origin)

            }
            .ignoresSafeArea()
    }
}

Solution

  • I think you want something like this:

    A stack of three circles. The top circle is red. The middle circle is yellow. The bottom circle is green. Under the stack is a segmented picker with segments "none", "red", "yellow", and "green". Initially the "none" segment is selected. Then i select the "red" segment. The circle stack dims except for a spotlighted area around the red circle. Then I click "green" and the spotlight animates to the green circle. I click "yellow" and the spotlight animates to the yellow circle. I click "none" and the dimming fades away. I click "red" and the dimming comes back while the spotlight animates to the red circle.

    One way to achieve this is using matchedGeometryEffect to put the spotlight over the selected light, and to use blendMode and compositingGroup to cut the hole in the darkening overlay.

    First, let's define a type to track which light is selected:

    enum Light: Hashable, CaseIterable {
        case red
        case yellow
        case green
    
        var color: Color {
            switch self {
            case .red: return .red
            case .yellow: return .yellow
            case .green: return .green
            }
        }
    }
    

    Now we can write a View that draws the colored lights. Each light is modified with matchedGeometryEffect to make its frame available for use by the spotlighting view (to be written later).

    struct LightsView: View {
        let namespace: Namespace.ID
    
        var body: some View {
            VStack(spacing: 20) {
                ForEach(Light.allCases, id: \.self) { light in
                    Circle()
                        .foregroundColor(light.color)
                        .matchedGeometryEffect(
                            id: light, in: namespace,
                            properties: .frame, anchor: .center,
                            isSource: true
                        )
                }
            }
            .padding(20)
        }
    }
    

    Here's the spotlighting view. It uses blendMode(.destinationOut) on a Circle to cut that circle out of the underlying Color.black, and uses compositingGroup to contain the blending to just the Circle and the Color.black.

    struct SpotlightView: View {
        var spotlitLight: Light
        var namespace: Namespace.ID
    
        var body: some View {
            ZStack {
                Color.black
                Circle()
                    .foregroundColor(.white)
                    .blur(radius: 4)
                    .padding(-10)
                    .matchedGeometryEffect(
                        id: spotlitLight, in: namespace,
                        properties: .frame, anchor: .center,
                        isSource: false
                    )
                    .blendMode(.destinationOut)
            }
            .compositingGroup()
        }
    }
    

    In HighlightingView, put the SpotlightView over the LightsView and animate the SpotlightView:

    struct HighlightingView: View {
        var spotlitLight: Light
        var isSpotlighting: Bool
        @Namespace private var namespace
    
        var body: some View {
            ZStack {
                LightsView(namespace: namespace)
    
                SpotlightView(
                    spotlitLight: spotlitLight,
                    namespace: namespace
                )
                .opacity(isSpotlighting ? 0.5 : 0)
                .animation(
                    .easeOut,
                    value: isSpotlighting ? spotlitLight : nil
                )
            }
        }
    }
    

    Finally, ContentView tracks the selection state and adds the Picker:

    struct ContentView: View {
        @State var isSpotlighting = false
        @State var spotlitLight: Light = .red
    
        private var selection: Binding<Light?> {
            Binding(
                get: { isSpotlighting ? spotlitLight : nil },
                set: {
                    if let light = $0 {
                        isSpotlighting = true
                        spotlitLight = light
                    } else {
                        isSpotlighting = false
                    }
                }
            )
        }
    
        var body: some View {
            VStack {
                HighlightingView(
                    spotlitLight: spotlitLight,
                    isSpotlighting: isSpotlighting
                )
    
                Picker("Light", selection: selection) {
                    Text("none").tag(Light?.none)
                    ForEach(Light.allCases, id: \.self) {
                        Text("\($0)" as String)
                            .tag(Optional($0))
                    }
                }
                .pickerStyle(.segmented)
            }
            .padding()
        }
    }