0

I need to make a SwiftUI tooltip component that supports being applied to any SwiftUI view, ideally via a ViewModifier. What I want to accomplish is pretty much exactly what AirBnb currently has, to be more precise, this: https://imgur.com/a/Y6uW4YX

The View should have the entire screen width minus 20pts horizontal margins on each side (as you can see on the AirBnb example).

My current implementation sorta works but layout breaks if the View is not exactly centered in the middle of the screen.

Current code:

    struct TestView: View {
    var body: some View {
        HStack {
            Rectangle()
                .frame(width: 100, height: 50)
                .foregroundStyle(Color.green)
                .tooltip()
        }
    }
}

extension View {
    func tooltip() -> some View {
        modifier(TooltipModifier())
    }
}

struct TooltipModifier: ViewModifier {
    
    func body(content: Content) -> some View {
        content
            .overlay(alignment: .bottom) {
                Tooltip()
                    .fixedSize()
                    .alignmentGuide(.bottom, computeValue: { dimension in
                        dimension[.top] - 20
                    })
            }
    }
}

struct Tooltip: View {
    var body: some View {
        ZStack(alignment: .top) {
            HStack {
                Text("This is some text that guides the user")
                Spacer()
                Image(systemName: "xmark.circle.fill")
                    .resizable()
                    .frame(width: 16, height: 16)
            }
            .padding(8)
            // This only works if the view is exactly in the middle of the screen, if not then alignment breaks.
            .frame(width: UIScreen.main.bounds.width - 40)
            .background(Color.red.opacity(0.5))
            .cornerRadius(8)
            
            Triangle()
                .fill(Color.red.opacity(0.5))
                .frame(width: 20, height: 10)
                .offset(y: -10)
        }
    }
}

struct Triangle: Shape {
    public func path(in rect: CGRect) -> Path {
        var path = Path()
        
        let topMiddle = CGPoint(x: rect.midX, y: rect.minY)
        let bottomLeft = CGPoint(x: rect.minX, y: rect.maxY)
        let bottomRight = CGPoint(x: rect.maxX, y: rect.maxY)
        
        path.move(to: bottomLeft)
        path.addLine(to: bottomRight)
        
        path.addArc(
            center: CGPoint(x: topMiddle.x, y: topMiddle.y),
            radius: 0,
            startAngle: .degrees(0),
            endAngle: .degrees(180),
            clockwise: true
        )
        
        path.addLine(to: bottomLeft)
        
        return path
    }
}

If the view is exactly centered in the middle of the screen such as what TestView currently has, the result is what I need:

enter image description here

But if we add a second rectangle to the main HStack so it is no longer in the middle of the screen, then layout breaks:

enter image description here

Any idea how can I make it so it takes the full width and supports views on any part of the screen? Tried using GeometryReaders but as usual, they seem to completely destroy my layout.

Thanks!

3
  • so you want the tooltip triangle to be at the centre of the view it is applied to and yet make the tool tip show it full content without changing its position ? Commented Feb 11 at 14:36
  • you should not apply tooltip to the individual views of the HStack, instead you should apply to the parent view or if possible the whole view, so that you would get the relative positions of contents of your view, and pass them to tool tip to adjust its position. Commented Feb 11 at 14:38
  • Hmm not sure I follow you, the tooltip should be "generic" and support being applied to any view, regardless of wether it is within a parent view or not . And yes, the triangle should always point to the middle of the view and the tooltip have the entire width regardless of the length of the text inside (Like the AirBnb screenshot example). If you could post some code of what you mean that would be great. Thanks! Commented Feb 11 at 14:46

1 Answer 1

2

If the tooltip is applied as an overlay to a view that is smaller than the width of the screen, it will need to break out of the overlay frame. You were doing this by setting a fixed width computed from UIScreen.main.bounds.width.

UIScreen.main is deprecated and doesn't work with iPad split screen. So it would be better to measure the screen width using an outer GeometryReader and then pass this width to the modifier. Then:

  • In order to be able to move the tooltip to the middle of the screen, the offset of the target view needs to be known. This offset can be measured using an .onGeometryChange modifier and passed to the ViewModifier too.

  • The arrow needs to respect the position of the target view. So it works well to use the arrow as the base of the tooltip, then show the message part as an overlay to the arrow.

Here is the updated example to show it working:

struct TestView: View {
    var body: some View {
        GeometryReader { proxy in
            let screenWidth = proxy.size.width
            HStack {
                Rectangle()
                    .frame(width: 100, height: 50)
                    .foregroundStyle(.green)
                    .tooltip(screenWidth: screenWidth)

                Rectangle()
                    .frame(width: 100, height: 50)
                    .foregroundStyle(.blue)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

extension View {
    func tooltip(screenWidth: CGFloat) -> some View {
        modifier(TooltipModifier(screenWidth: screenWidth))
    }
}

struct TooltipModifier: ViewModifier {
    let screenWidth: CGFloat
    @State private var originX = CGFloat.zero

    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGFloat.self) { proxy in
                proxy.frame(in: .global).minX
            } action: { minX in
                originX = minX
            }
            .overlay(alignment: .bottom) {
                Tooltip(screenWidth: screenWidth, originX: originX)
                    .alignmentGuide(.bottom) { dimension in
                        dimension[.top]
                    }
            }
    }
}

struct Tooltip: View {
    let screenWidth: CGFloat
    let originX: CGFloat

    var body: some View {
        Triangle()
            .fill(Color.red.opacity(0.5))
            .frame(width: 20, height: 10)
            .frame(maxWidth: .infinity)
            .overlay(alignment: .topLeading) {
                HStack {
                    Text("This is some text that guides the user")
                    Spacer()
                    Image(systemName: "xmark.circle.fill")
                        .resizable()
                        .frame(width: 16, height: 16)
                }
                .fixedSize(horizontal: false, vertical: true)
                .padding(8)
                .background(.red.opacity(0.5), in: .rect(cornerRadius: 8))
                .padding(.horizontal, 20)
                .frame(width: screenWidth, alignment: .leading)
                .offset(x: -originX)
                .padding(.top, 10)
            }
            .padding(.top, 10)
    }
}

Screenshot


You may discover that the tooltip will be covered by any content that follows the target view in the layout. For example, if the HStack in the example is inside a VStack and followed by some Text, the text will cover the tooltip:

GeometryReader { proxy in
    let screenWidth = proxy.size.width
    VStack {
        HStack {
            // ... as above
        }
        Text(loremIpsum) // covers the tooltip 🙁
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}

Ways to solve:

  • Add padding below the target view, so that the tooltip is shown in this space.

  • Show the tooltip above the target view (with arrow pointing down), instead of below it.

  • Isolate the target view as the top view in a ZStack.

  • Show the tooltip as a layer in a ZStack and position it using .matchedGeometryEffect, instead of showing it as an overlay. This is the technique being used in the answer to iOS SwiftUI Need to Display Popover Without "Arrow" (it was my answer).


EDIT Regarding your follow-up questions:

  1. Im trying to make this as reusable and generic as possible so others can use it on their own views as well, my first question is why should the caller provide the screen width to the component? Wouldnt it be better for the component itself to grab the screen width? But then again, GeometryReader wouldnt work as that would grab the width of the immediate parent of the view the modifier is being applied to and not the actual screen width, right (in this simple example the parent of the rectangle is indeed the entire screen but that may not always be the case)? I guess UIScreen would work but as you well said its deprecated and a bad practice afaik.

Correct. The size needs to be passed in from a view that occupies the full screen width (or at least, the full width to be used). A child view can not just read this from somewhere, since UIScreen.main should be avoided.

  1. How would you manage dismissing the tooltip once the X is tapped? Would a boolean Binding within TooltipModifier be enough or is there some other fancier way to handle this type of state nowadays?

A boolean flag would work fine. Or you could pass in a function to call (like a custom dismiss function).

Sign up to request clarification or add additional context in comments.

6 Comments

Wow. Thank you so much Benzy! StackOverflow limits my reply character limit and I wanna be as detailed as possible so, if thats OK with you, I went ahead and replied in a pastebin. Thanks! pastebin.com/aNWWFHtt
@stompy I added your follow-up questions to the answer, with answers :)
Great!! I imagined there would be no way of knowing the full width within subviews without relying on UIScreen. Thank you so much Benzy. I gave it a try on my app yesterday and it seems to work as expected! Next step is probably adjusting it so the width is not always the entire width but dynamic (as in making it configurable so it adjusts to entire width if desired or just what it needs depending on text length, I gave it a shot yesterday but failed sadly). This has been more than enough help from your side Benzy, thank you so much!
@stompy Yes, if running on an iPad then you probably won't want the tip to use the full screen width. In this case, you could just pass in the maximum width it should use, instead of the full screen width. Ps. Thanks for accepting the answer!
No worries, least I could do for such quality help! And yeah, I actually tried calling the modifier with a different width such as 350px (full width of the screen is 400 approx) and while the tooltip did size smaller, it did not layout correctly with the pointer and the target view. Example: imgur.com/a/EFYosGd , I will see if I can dwelve deeper in the implementation and figure what may be going wrong. Thanks :)
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.