tooltip view SwiftUI

1.2k views Asked by At

I created tool tip in swiftui, but when I use it with overlay - frame is break.

import SwiftUI

struct HintBox: View {
    @State private var showTooltip = true
    
    var body: some View {
        VStack {
            Text("Here text")
                    TooltipView(text: "it's hint! it's hint! it's hint! it's hint! it's hint! it's hint!", isVisible: $showTooltip)
                        .frame(maxWidth: .infinity)
                        .onTapGesture {
                            showTooltip.toggle()
                        }
        }
    }
}

struct TooltipView: View {
    var text: String
    @Binding var isVisible: Bool
    var body: some View {
        ZStack(alignment: .top) {
            if isVisible {
                Text(text)
                    .padding()
                    .background(Color.gray)
                    .foregroundColor(.white)
                    .cornerRadius(8)
                
                
                Triangle()
                    .fill(Color.gray)
                    .frame(width: 20, height: 10)
                    .offset(y: 0)
            }
        }
    }
}

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.midX - 10, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.midX, y: rect.minY - 10))
        path.addLine(to: CGPoint(x: rect.midX + 10, y: rect.minY))
        path.closeSubpath()
        return path
    }
}

I need to make it so that I can determine exactly where the triangle will be (bottom or top), in the middle, or left or right. and also, it must be above other views. i.e. point with a triangle at the view to which I will attach it. and when you click on it, it should stop being displayed

3

There are 3 answers

5
Benzy Neez On

You might find that a popover gives you the functionality you want, without having to re-invent it. However, on an iPhone, a popover is shown as a sheet.

Otherwise, one way to implement this is to show the hint as an overlay over the source view. An overlay automatically adopts the size of the underlying view and using a GeometryReader you can find the size of the source view in this way.

You want the hint to be able to break out of the bounds of the source footprint. This is done by using .fixedSize(). However, this means, the overlay is no longer centered, so you have to apply an offset to bring it back to center.

Then, to align the hint above, below, before or after the source view, you need to know the size of the hint itself. Here you can use the same technique again, setting an overlay over a hidden version of the hint and using another GeometryReader to find its size.

Here is an attempt to show it working:

EDIT: Updated to take a closure as parameter. If the hint consists of long text then the caller can set a sensible width for it.

struct HintBox: View {
    @State private var showTooltip = true

    var body: some View {
        VStack {
            Text("Text here")
                .overlay {
                    TooltipView(
                        alignment: .top,
                        isVisible: $showTooltip
                    ) {
                        Text("it's hint! it's hint! it's hint! it's hint! it's hint! it's hint!")
                            .frame(width: 200)
                    }
                }
                .onTapGesture {
                    showTooltip.toggle()
                }
        }
    }
}

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(8)
            .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()
            }
        }
    }
}

HintWithWrappedText

8
lazarevzubov On

Benzy Neez's answer is great, but it can be further improved.

First of all, we can combine the solution with Mojtaba Hosseini's technique of reading the size of a sibling view described here. The resulting code is more neat and doesn't require multiple GeometryReaders or hidden copies of child views.

Secondly, the padding and background color can be a part of the API. Additionally, appearing/disappearing can be animated.

The implementation will looks like this:

private struct TooltipPopup<Content: View, Tooltip: View>: View {

  var body: some View {
    ChildSizeReader(size: $contentSize) {
      content
    }
      .overlay {
        hint
          .drawingGroup()
          .frame(maxWidth: .infinity)
          .opacity(visible ? 1.0 : .zero)
          .animation(.default, value: visible)
          .shadow(radius: .halfPadding)
          .offset(x: (alignment == .leading) ? (-hintSize.width / 2.0) - (contentSize.width / 2.0) : .zero)
          .offset(x: (alignment == .trailing) ? (hintSize.width / 2.0) + (contentSize.width / 2.0) : .zero)
          .offset(y: (alignment == .top) ? (-hintSize.height / 2.0) - (contentSize.height / 2.0) : .zero)
          .offset(y: (alignment == .bottom) ? (hintSize.height / 2.0) + (contentSize.height / 2.0) : .zero)
        }
  }

  private let alignment: Edge
  private let backgroundColor: Color
  private let content: Content
  @ViewBuilder private let tooltip: () -> Tooltip
  @State private var contentSize = CGSize.zero
  private var hint: some View {
    ChildSizeReader(size: $hintSize) {
      tooltip()
        .background(backgroundColor)
        .cornerRadius(.defaultCornerRadius)
        .background(alignment: oppositeAlignment) {
          Rectangle() // The arrow is a square rotated by 45 degrees.
            .fill(backgroundColor)
            .frame(square: .fullPadding)
            .rotationEffect(.degrees(45.0))
            .offset(x: (alignment == .leading) ? .halfPadding : .zero)
            .offset(x: (alignment == .trailing) ? -.halfPadding : .zero)
            .offset(y: (alignment == .top) ? .halfPadding : .zero)
            .offset(y: (alignment == .bottom) ? -.halfPadding : .zero)
        }
        .padding(.fullPadding)
    }
  }
  @State private var hintSize = CGSize.zero
  private var oppositeAlignment: Alignment {
    switch alignment {
      case .top:
        .bottom
      case .bottom:
        .top
      case .leading:
        .trailing
      case .trailing:
        .leading
    }
  }
  @Binding private var visible: Bool

  init(content: Content, alignment: Edge, visible: Binding<Bool>, backgroundColor: Color = .white, @ViewBuilder tooltip: @escaping () -> Tooltip) {
        self.content = content
        self.alignment = alignment
        self.backgroundColor = backgroundColor
        self.tooltip = tooltip

        _visible = visible
    }

}

Which implies you have ChildSizeReader defined and implemented as this:

struct ChildSizeReader<Content: View>: View {

  var body: some View {
    content()
      .background(GeometryReader {
        Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
      })
      .onPreferenceChange(SizePreferenceKey.self) { size = $0 }
  }

  private let content: () -> Content
  @Binding private var size: CGSize

  init(size: Binding<CGSize>, content: @escaping () -> Content) {
    self.content = content
    _size = size
  }

}

private struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value _: inout Value, nextValue: () -> Value) { }

Finally, we can turn the view into a view modifier:

extension View {
  func tooltip(alignment: Edge, visible: Binding<Bool>, backgroundColor: Color = .white, @ViewBuilder tooltip: @escaping () -> some View) -> some View {
    modifier(TooltipDisplayingModifier(alignment: alignment, visible: visible, backgroundColor: backgroundColor, tooltip: tooltip))
  }
}

private struct TooltipDisplayingModifier<Tooltip: View>: ViewModifier {

  private let alignment: Edge
  private let backgroundColor: Color
  @ViewBuilder private let tooltip: () -> Tooltip
  @Binding private var visible: Bool

  init(alignment: Edge, visible: Binding<Bool>, backgroundColor: Color = .white, @ViewBuilder tooltip: @escaping () -> Tooltip) {
    self.alignment = alignment
    self.backgroundColor = backgroundColor
    self.tooltip = tooltip

    _visible = visible
  }

  func body(content: Content) -> some View {
    TooltipPopup(content: content, alignment: alignment, visible: $visible, backgroundColor: backgroundColor, tooltip: tooltip)
  }

}

...and use it like this:

Text("I'm a text")
  .tooltip(alignment: .top, visible: .constant(true), backgroundColor: .green) {
    Text("I'm a tooltip")
      .frame(width: 100.0)
      .padding(.fullPadding)
  }

Since the view the tooltip "tips" typically is just a leaf view often deep inside the view hierarchy, the view modifier is not aware of the presence or size of its parent views. Because of this, tooltip needs to be sized from the outside. In simple cases, providing a width is enough, but in more complicated ones, more information on layout is necessary. For example (again, with a help of ChildSizeReader):

ChildSizeReader(size: $contentSize) {
  VStack {
    FancyScreenCode()
    Button(action: action) {
      Text("What is this?")
    }
      .tooltip(alignment: .top, visible: $visible) {
        FancyTooltipContent()
          .frame(width: contentSize.width - (2.0 * 8.0))
          .fixedSize()
      }
  }
}
3
Gourav Mandliya On

This is from Kodeco https://www.kodeco.com/books/swiftui-cookbook/v1.0/chapters/5-create-a-popover-in-swiftui

struct ContentView: View {
  @State private var showPopover = false

  var body: some View {
    Button("Show Popover") {
      showPopover.toggle()
    }
    .buttonStyle(.borderedProminent)
    .popover(isPresented: $showPopover,
             attachmentAnchor: .point(.topLeading),
             content: {
      Text("This is a Popover")
        .padding()
        .frame(minWidth: 300, maxHeight: 400)
        .background(.red)
        .presentationCompactAdaptation(.popover)
    })
  }
}