Wednesday, December 20, 2023
HomeiOS Developmentios - SwiftUI - Reusable Guided Tour & Highlight impact View /...

ios – SwiftUI – Reusable Guided Tour & Highlight impact View / Element


So principally I must create a view that represents a Guided Tour of my app so customers are conversant in the options and components my app presents. This consists of reducing the opacity of the whole display screen whereas spotlighting a particular a part of the view + including a speech bubble that factors to this highlight with some Textual content views + buttons. Typical walkthrough / onboarding move. One thing like this.

This must be generic and reusable so it really works when presenting it above UIKit ViewControllers in addition to SwiftUI views (that are wrapped in UIHostingControllers both approach). For UIKit ViewControllers I believed presenting this view with vc.current(UIHostingController(rootView: DimmingView())) and on SwiftUI with perhaps a .fullScreenCover modifier (although Im open to options, ofc).

My concept has been to create a view that receives an array of things to be spotlighted so I can iterate by means of it and present the spotlights in steps as soon as at a time, an array of one thing akin to:



struct GuidedTourItem {
    let textual content: String
    let frameToSpotlight: CGRect //body of the UI factor to be spotlighted, wanted to attract the rounded rectangle and place the speech bubble
}

With that I have already got all I want to attract the highlight (the textual content + the body of the factor to be spotlighted to place and dimension the Speech bubble + highlight).

My present code solely has one factor to be spotlighted, nevertheless it must assist N steps.


Present minimal reproducible instance goes as follows:

ViewModier and View extension to attract the Speech Bubble (Taken from this SO reply so credit to the creator):

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

struct ToolTipModifier: ViewModifier {
    @Binding var isVisible: Bool
    
    func physique(content material: Content material) -> some View {
        content material
            .overlay(
                TooltipView(alignment: .backside, isVisible: $isVisible) {
                    Textual content("This View / Speech bubble ought to take the whole display screen width minus 20pts horizontal padding and top be dynamic relying on what views are inside. Lastly, the insides of the above rounded rectangle shouldn't be dimmed in any respect and ought to be clear so it creates the highlight impact")
                    //TODO: Work out the way to make it so the width isnt hardcoded however as a substitute takes the whole display screen width minus 20pts horizontal padding.
                        .body(width: 200)
                }
            )
    }
}

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

    personal var oppositeAlignment: Alignment {
        let consequence: Alignment
        change alignment {
        case .high: consequence = .backside
        case .backside: consequence = .high
        case .main: consequence = .trailing
        case .trailing: consequence = .main
        }
        return consequence
    }

    personal var theHint: some View {
        content material()
            .padding()
            .background(Coloration.grey)
            .foregroundColor(.white)
            .cornerRadius(20)
            .background(alignment: oppositeAlignment) {

                // The arrow is a sq. that's rotated by 45 levels
                Rectangle()
                    .fill(Coloration.grey)
                    .body(width: 22, top: 22)
                    .rotationEffect(.levels(45))
                    .offset(x: alignment == .main ? arrowOffset : 0)
                    .offset(x: alignment == .trailing ? -arrowOffset : 0)
                    .offset(y: alignment == .high ? arrowOffset : 0)
                    .offset(y: alignment == .backside ? -arrowOffset : 0)
            }
            .padding()
            .fixedSize()
    }

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

                // Use a hidden model of the trace to type the footprint
                theHint
                    .hidden()
                    .overlay {
                        GeometryReader { proxy2 in

                            // The seen model of the trace
                            theHint
                                .drawingGroup()
                                .shadow(radius: 4)

                                // Middle the trace over the supply view
                                .offset(
                                    x: -(proxy2.dimension.width / 2) + (proxy1.dimension.width / 2),
                                    y: -(proxy2.dimension.top / 2) + (proxy1.dimension.top / 2)
                                )
                                // Transfer the trace to the required edge
                                .offset(x: alignment == .main ? (-proxy2.dimension.width / 2) - (proxy1.dimension.width / 2) : 0)
                                .offset(x: alignment == .trailing ? (proxy2.dimension.width / 2) + (proxy1.dimension.width / 2) : 0)
                                .offset(y: alignment == .high ? (-proxy2.dimension.top / 2) - (proxy1.dimension.top / 2) : 0)
                                .offset(y: alignment == .backside ? (proxy2.dimension.top / 2) + (proxy1.dimension.top / 2) : 0)
                        }
                    }
            }
            .onTapGesture {
                isVisible.toggle()
            }
        }
    }
}

View

struct ContentView: View {
    
    @State personal var showingDimmedOverlay = false
    let testFrame = CGRect(x: 20, y: 104, width: 240, top: 48)
    
    var physique: some View {
        VStack(spacing: 0) {
            Coloration.inexperienced
            Textual content("Toggle Modal")
                .padding()
                .body(maxWidth: .infinity)
                .background(Coloration.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 physique: some View {
        ZStack {
            Coloration.black
                .opacity(0.3)
                .edgesIgnoringSafeArea(.all)
                .background(BackgroundClearView())
                .onTapGesture {
                    showingDimmedOverlay.toggle()
                }
            RoundedRectangle(cornerRadius: 16)
                .stroke(.white, lineWidth: 4)
                .tooltip(enabled: .fixed(true))
                .body(width: testFrame.width + 15, top: testFrame.top + 5)
            // place is the middle of the view
                .place(x: testFrame.midX,
                          y: testFrame.midY)
            
        }
    }
}

UIViewRepresentable to make the background clear (that is in all probability fallacious however havent discovered something that works with fullScreenCover, any various?):

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

All this code produces the next consequence: Consequence

So my questions are:

  1. How can I make it so I dont must hardcode the width of the speech bubble contents, as you may see on the code for the TooltipModifier, it has a hardcoded 200pts width, I want the speech bubble to all the time take the whole width of the display screen minus 20pts horizontal padding/margin. Because the creator stated on that thread, it appears setting one thing like .body(maxWidth: .infinity) doesnt work as a result of it’s set as an overlay and thus takes the whole width of the overlayed View as a substitute of the whole display screen, however sadly I havent been in a position to get a lot progress overcoming this difficulty.
  2. How can I make it so the insides of the rounded rectangle arent dimmed and the inexperienced coloration is proven at its full opacity in order to create that highlight impact that highlights a portion of the View?

Any pointers are drastically appreciated and my apologies for the massive wall of textual content/code, I attempted offering as a lot element as doable.



Supply hyperlink

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments