ios – SwiftUI – Reusable Guided Tour & Spotlight effect View / Component


So basically I need to create a view that represents a Guided Tour of my app so users are familiar with the features and elements my app offers. This consists of lowering the opacity of the entire screen while spotlighting a specific part of the view + adding a speech bubble that points to this spotlight with some Text views + buttons. Typical walkthrough / onboarding flow. Something like this.

This needs to be generic and reusable so it works when presenting it above UIKit ViewControllers as well as SwiftUI views (which are wrapped in UIHostingControllers either way). For UIKit ViewControllers I thought presenting this view with vc.present(UIHostingController(rootView: DimmingView())) and on SwiftUI with maybe a .fullScreenCover modifier (though Im open to alternatives, ofc).

My idea has been to create a view that receives an array of items to be spotlighted so I can iterate through it and show the spotlights in steps once at a time, an array of something such as:



struct GuidedTourItem {
    let text: String
    let frameToSpotlight: CGRect //frame of the UI element to be spotlighted, needed to draw the rounded rectangle and position the speech bubble
}

With that I already have all I need to draw the spotlight (the text + the frame of the element to be spotlighted to position and size the Speech bubble + spotlight).

My current code only has one element to be spotlighted, but it needs to support N steps.


Current minimum reproducible example goes as follows:

ViewModier and View extension to draw the Speech Bubble (Taken from this SO answer so credits to the author):

extension View {
    func tooltip(enabled: Binding<Bool>) -> some View {
        modifier(ToolTipModifier(isVisible: enabled))
    }
}

struct ToolTipModifier: ViewModifier {
    @Binding var isVisible: Bool
    
    func body(content: Content) -> some View {
        content
            .overlay(
                TooltipView(alignment: .bottom, isVisible: $isVisible) {
                    Text("This View / Speech bubble should take the entire screen width minus 20pts horizontal padding and height be dynamic depending on what views are inside. Lastly, the insides of the above rounded rectangle should not be dimmed at all and should be transparent so it creates the spotlight effect")
                    //TODO: Figure out how to make it so the width isnt hardcoded but instead takes the entire screen width minus 20pts horizontal padding.
                        .frame(width: 200)
                }
            )
    }
}

struct TooltipView<Content: View>: View {
    let alignment: Edge
    @Binding var isVisible: Bool
    let content: () -> Content
    let arrowOffset = CGFloat(8)

    private var oppositeAlignment: Alignment {
        let result: Alignment
        switch alignment {
        case .top: result = .bottom
        case .bottom: result = .top
        case .leading: result = .trailing
        case .trailing: result = .leading
        }
        return result
    }

    private var theHint: some View {
        content()
            .padding()
            .background(Color.gray)
            .foregroundColor(.white)
            .cornerRadius(20)
            .background(alignment: oppositeAlignment) {

                // The arrow is a square that is rotated by 45 degrees
                Rectangle()
                    .fill(Color.gray)
                    .frame(width: 22, height: 22)
                    .rotationEffect(.degrees(45))
                    .offset(x: alignment == .leading ? arrowOffset : 0)
                    .offset(x: alignment == .trailing ? -arrowOffset : 0)
                    .offset(y: alignment == .top ? arrowOffset : 0)
                    .offset(y: alignment == .bottom ? -arrowOffset : 0)
            }
            .padding()
            .fixedSize()
    }

    var body: some View {
        if isVisible {
            GeometryReader { proxy1 in

                // Use a hidden version of the hint to form the footprint
                theHint
                    .hidden()
                    .overlay {
                        GeometryReader { proxy2 in

                            // The visible version of the hint
                            theHint
                                .drawingGroup()
                                .shadow(radius: 4)

                                // Center the hint over the source view
                                .offset(
                                    x: -(proxy2.size.width / 2) + (proxy1.size.width / 2),
                                    y: -(proxy2.size.height / 2) + (proxy1.size.height / 2)
                                )
                                // Move the hint to the required edge
                                .offset(x: alignment == .leading ? (-proxy2.size.width / 2) - (proxy1.size.width / 2) : 0)
                                .offset(x: alignment == .trailing ? (proxy2.size.width / 2) + (proxy1.size.width / 2) : 0)
                                .offset(y: alignment == .top ? (-proxy2.size.height / 2) - (proxy1.size.height / 2) : 0)
                                .offset(y: alignment == .bottom ? (proxy2.size.height / 2) + (proxy1.size.height / 2) : 0)
                        }
                    }
            }
            .onTapGesture {
                isVisible.toggle()
            }
        }
    }
}

View

struct ContentView: View {
    
    @State private var showingDimmedOverlay = false
    let testFrame = CGRect(x: 20, y: 104, width: 240, height: 48)
    
    var body: some View {
        VStack(spacing: 0) {
            Color.green
            Text("Toggle Modal")
                .padding()
                .frame(maxWidth: .infinity)
                .background(Color.blue)
                .onTapGesture {
                    showingDimmedOverlay.toggle()
                }
        }
        .edgesIgnoringSafeArea(.all)
        .fullScreenCover(isPresented: $showingDimmedOverlay) {
            DimmingView(showingDimmedOverlay: $showingDimmedOverlay, testFrame: testFrame)
        }
    }
}


struct DimmingView: View {
    
    @Binding var showingDimmedOverlay: Bool
    let testFrame: CGRect
    
    var body: some View {
        ZStack {
            Color.black
                .opacity(0.3)
                .edgesIgnoringSafeArea(.all)
                .background(BackgroundClearView())
                .onTapGesture {
                    showingDimmedOverlay.toggle()
                }
            RoundedRectangle(cornerRadius: 16)
                .stroke(.white, lineWidth: 4)
                .tooltip(enabled: .constant(true))
                .frame(width: testFrame.width + 15, height: testFrame.height + 5)
            // position is the center of the view
                .position(x: testFrame.midX,
                          y: testFrame.midY)
            
        }
    }
}

UIViewRepresentable to make the background transparent (this is probably wrong but havent found anything that works with fullScreenCover, any alternative?):

struct BackgroundClearView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        DispatchQueue.main.async {
            view.superview?.superview?.backgroundColor = .clear
        }
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {}
}

All this code produces the following result: Result

So my questions are:

  1. How can I make it so I dont have to hardcode the width of the speech bubble contents, as you can see on the code for the TooltipModifier, it has a hardcoded 200pts width, I need the speech bubble to always take the entire width of the screen minus 20pts horizontal padding/margin. As the author said on that thread, it seems setting something like .frame(maxWidth: .infinity) doesnt work because it is set as an overlay and thus takes the entire width of the overlayed View instead of the entire screen, but sadly I havent been able to get much progress overcoming this issue.
  2. How can I make it so the insides of the rounded rectangle arent dimmed and the green color is shown at its full opacity so as to create that spotlight effect that highlights a portion of the View?

Any pointers are greatly appreciated and my apologies for the large wall of text/code, I tried providing as much detail as possible.

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img