3

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()
    }
}
Jared
  • 793
  • 6
  • 16
  • 1
    Just to throw a crazy idea out (there might be a _much_ better way): I wonder if you could actually _mostly_ duplicate the `View` (except for the highlighted item) and place one on top of the other with a `ZStack` and then use a blendMode to create the desired effect. – jnpdx Jan 27 '23 at 17:04
  • I like the jnpdx idea, but WHEN the circle should be highlighted? OnClick or OnHover? – Allan Garcia Jan 27 '23 at 17:10
  • Seems like a decision for the OP – jnpdx Jan 27 '23 at 17:16

4 Answers4

5

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()
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Surprisingly technical to do in SwiftUI. `matchedGeometryEffect` is definitely the key. Thank you Rob! – Jared Feb 18 '23 at 17:07
1

Allow me to add another approach. If the size of the negative highlight can stay the same for all views, then you don't need any GeometryReader.

Of course you can also pass individual sizes to the func.

I packed the highlight func into a View extension that can conveniently be used as a view modifier.

enter image description here

struct ContentView: View {
    
    @State private var highlight = 0
    
    var body: some View {
        
        ZStack {
            VStack {
                Spacer()
                Circle()
                    .fill(.blue)
                    .frame(width: 100)
                    .negativeHighlight(enabled: highlight == 0)

                Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi laoreet elementum purus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Cras vel ipsum et risus vulputate auctor non ac ligula.")
                    .padding()
                    .negativeHighlight(enabled: highlight == 1)

                RoundedRectangle(cornerRadius: 10)
                    .fill(.red)
                    .frame(width: 50, height: 80)
                    .negativeHighlight(enabled: highlight == 2)

                HStack {
                    Rectangle()
                        .fill(.green)
                        .frame(width: 100, height: 100)
                        .padding()
                        .negativeHighlight(enabled: highlight == 3)
                    
                    Circle()
                        .fill(.yellow)
                        .frame(width: 100)
                        .negativeHighlight(enabled: highlight == 4)
                }

                Spacer()

                // move to next view
                Button("Show Next") { highlight = (highlight + 1) % 6 }
                .buttonStyle(.borderedProminent)
                .zIndex(2)
            }
        }
    }
}


extension View {
    
    func negativeHighlight(enabled: Bool) -> some View {
        self
            .overlay(
                Color.black.opacity(0.5)
                    .reverseMask {
                        Circle()
                            .fill(.blue)
                            .frame(width: 150)
                    }
                    .frame(width: 10_000, height: 10_000)
                    .opacity(enabled ? 1 : 0)
            )
            .zIndex(enabled ? 1 : 0)
    }
        
    
    @inlinable func reverseMask<Mask: View>(
        alignment: Alignment = .center,
        @ViewBuilder _ mask: () -> Mask
    ) -> some View {
            self.mask(
                ZStack {
                    Rectangle()
                    mask()
                        .blendMode(.destinationOut)
                }
            )
        }
}
ChrisR
  • 9,523
  • 1
  • 8
  • 26
  • Thank you ChrisR. Very clever solution. Can I ask, what does the value '10_000' do? I've never seen that. – Jared Jan 30 '23 at 15:04
  • 2
    :) I have to give the transparent black `.overlay` a fixed frame size, so it gets bigger than its parent – and 10.000 ist just an arbitrary big enough number for most displays. the notation `10_000` is identical to `10000`, just better to read. The compiler ignores the `_`. – ChrisR Jan 30 '23 at 17:58
0

You can do it this way. Using geometry size is not "hacky" at all. The "styling" of the circles you can do whatever you like.


//
//  ContentView.swift
//  SelectedCircle
//
//  Created by Allan Garcia on 27/01/23.
//

import SwiftUI

enum SelectedColor {
    case red
    case blue
    case green
    case none
}

struct ContentView: View {
    
    @State private var selectedColor: SelectedColor = .none
    
    var body: some View {
        GeometryReader { geometry in
            let minSize = min(geometry.size.height, geometry.size.width) * 0.5
            let offset = minSize * 1.2
            ZStack {
                Color.white.zIndex(-100) // background
                if selectedColor != .none {
                    Color.black
                        .opacity(0.9)
                        .zIndex(selectedColor != .none ? 50 : 0)
                        .onTapGesture { selectedColor = .none }
                } // Fade out
                Circle()
                    .fill(Color.red)
                    .zIndex(selectedColor != .none && selectedColor == .red ? 100 : 0)
                    .onTapGesture { selectedColor = .red }
                    .offset(y: offset)
                    .frame(width: minSize, height: minSize)
                Circle()
                    .fill(Color.blue)
                    .zIndex(selectedColor != .none && selectedColor == .blue ? 100 : 0)
                    .onTapGesture { selectedColor = .blue }
                    .frame(width: minSize, height: minSize)
                Circle()
                    .fill(Color.green)
                    .zIndex(selectedColor != .none && selectedColor == .green ? 100 : 0)
                    .onTapGesture { selectedColor = .green }
                    .offset(y: -offset)
                    .frame(width: minSize, height: minSize)
            }
            .ignoresSafeArea(.all)
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Let me know if something was unclear. You can also change onTapGesture to any other gesture you want. The enum, if make sense, could be moved to your model, but I strongly believe is a state of the view, so it must live in the view.

EDIT: This will not modify the "behavior" of the view, you are changing the State, that's the most Swifty way of doing this. The change in the State will trigger the redraw of the view and the view will reflect the new State that changed.

EDIT2: Published at: https://github.com/allangarcia/SelectedCircle

Allan Garcia
  • 550
  • 3
  • 16
  • Agree that using `GeometryReader` isn't hacky, but in this case, it also sacrifices any layout that SwiftUI does automatically with stacks (thus all of the specific `offset` modifiers) – jnpdx Jan 27 '23 at 17:54
  • Yes, but you can't have zIndex changed inside a VStack inside a ZStack. zIndex only work inside the same ZStack. To use VStacks your code will be messy, more than using offset and frame. As you can see, the Frame will be proportional to the space it have (50%) and the offset will be also proportional to the size (20%) of space. You can change those values and find a layout that work well in various devices. For the extremes (like macOS) you can show another View. – Allan Garcia Jan 27 '23 at 18:01
  • The jnpdx idea is not bad either. You could have three views one above the other in the same ZStack, all with the exact position of the first view that show all three circles. But will be much more duplicate code. Works, but if was me coding, I probably go with this solution I showed to you. – Allan Garcia Jan 27 '23 at 18:05
  • The enum BTW could be putted inside the struct ContentView... – Allan Garcia Jan 27 '23 at 18:07
0

It looks like you've already received a helpful answer to your question, although my answer won't be different from the accepted answer. I just wanted to mention that I've developed a lightweight library called FutureFlow that specifically aims to help create engaging and informative tutorials within SwiftUI-based apps. It provides a simple way to highlight specific views and guide users through the key features of your app seamlessly. I am only posting here because I thought it might be useful to others.

Here's a quick example of how you could use FutureFlow to achieve your goal:

enum Circles: FlowChunk, CaseIterable {
    case red
    case blue
    case green
}

Next, apply the FutureFlow library to your SwiftUI view with the circles:

You only need to apply configureChunkForSpotlight to the elements which you want to shine a spotlight on and assembleSpotlightChunks on the parent wrapper which contains those elements.

Like this

import SwiftUI
import FutureFlow

struct ContentView: View {
    private var uniqueIdentifier: String = UUID().uuidString // Define a unique identifier for this view
    
    @State private var showTutorial: Bool = true // Toggle the spotlight
    @Namespace var namespace // A namespace for the animations
    
    var body: some View {
        VStack(spacing: 50) {

            Circle()
                .frame(width: 128, height: 128, alignment: .center)
                .foregroundColor(.red)
                .configureChunkForSpotlight(
                    namespace: self.namespace,
                    parentIdentifier: self.uniqueIdentifier,
                    chunk: Circles.red
                )

            Circle()
                .frame(width: 128, height: 128, alignment: .center)
                .foregroundColor(.blue)
                .configureChunkForSpotlight(
                    namespace: self.namespace,
                    parentIdentifier: self.uniqueIdentifier,
                    chunk: Circles.blue
                )

            Circle()
                .frame(width: 128, height: 128, alignment: .center)
                .foregroundColor(.green)
                .configureChunkForSpotlight(
                    namespace: self.namespace,
                    parentIdentifier: self.uniqueIdentifier,
                    chunk: Circles.green
                )
        }
        .padding()
        .assembleSpotlightChunks(namespace: self.namespace, uniqueIdentifier: self.uniqueIdentifier, chunks: Array(Circles.allCases), showTutorial: self.$showTutorial) {
            chunk in
            print("A new step")
        }
    }
}

You can also add a view for each spotlight, for example

enum Circles: FlowChunk, CaseIterable {
    case red
    case blue
    case green

    var spotlightShape: SpotlightShape {
        return .circle()
    }

    func instructionsView(_ next: @escaping () -> (), _ back: @escaping () -> ()) -> AnyInstructionsView? {
        simpleInstructionsView(next: next)
    }
}

struct simpleInstructionsView: InstructionsView {
    let next: () -> ()
    var body: some View {
        Button(action: {
            self.next()
        }) {
            Text("Next")
                .foregroundColor(.white)
        }
    }
}

which gives you more freedom in when to move next and etc...

here is a demo

demo gif

And that's it really. This code snippet demonstrates how FutureFlow can be used to highlight the blue circle with a minimal amount of code. You can customize the spotlight shape, background, and other properties by updating the Circles enum. For more information and examples, check out the library on GitHub.

I hope this helps! Let me know if you have any questions or if there's anything else I can do to assist you.

P.S.

It can be installed using Swift Package Manager by going to xCode -> File -> Swift Packages -> Add Package Dependency, and paste the following URL into the search field:

https://github.com/xyfuture-llc/FutureFlow.git
Muhand Jumah
  • 1,726
  • 1
  • 11
  • 26