Search code examples
iosswiftswiftui

SwiftUI ToolTip View component alignment issues


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!


Solution

  • 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).