This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We implement a custom tooltip API that only shows one tooltip at a time.

00:06 It feels like we're in Groundhog Day, returning with another remote episode after ending the last one saying we hoped to be back into the studio — but here we are. It'll probably take one more week after this before the repairs are done.

00:32 Today, we're going to build some tooltips. In SwiftUI, we can call help on a view to add a built-in tooltip on macOS, but this modifier only takes a string. Also, the built-in tooltip appears when we hover over the original view, but we want slightly different behavior.

Writing a Tooltip Modifier

00:49 We want to create an API that lets us call tooltip with any kind of view, and the tooltip should appear when we tap on the view. For example, we might want to create a tooltip from a stack containing a text view and a small circle, and attach it to an icon view like so:

struct ContentView: View {
    var body: some View {
        HStack(spacing: 32) {
            Image(systemName: "house")
                .tooltip {
                    HStack {
                        Text("Home")
                        Circle()
                            .fill(.green)
                            .frame(width: 10, height: 10)
                    }
                }
            Image(systemName: "video")
            Image(systemName: "mic")

        }
        .font(.largeTitle)
        .padding()
    }
}

01:22 We're going to build the tooltip as a view modifier, so we start by writing a helper method that creates the modifier. Both the helper and the modifier are generic over the type of view we pass in as the tooltip:

extension View {
    func tooltip<Other: View>(@ViewBuilder other: () -> Other) -> some View {
        modifier(TooltipModifier(tooltipContent: other()))
    }
}

01:54 In the view modifier's body method, we place the tooltip view in an overlay over the original content view:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                tooltipContent
            }
    }
}

02:44 We're not quite there yet. By calling fixedSize on the tooltip view, we make sure it draws at its ideal size, even if that's outside the bounds of the underlying view. Also, we've configured our icons to have the largeTitle font, but we don't want the tooltip to inherit that part of the environment, so we set the font to body inside the modifier. To finish the look, we add a background with rounded corners as well as a small drop shadow:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                tooltipContent
                    .padding(8)
                    .background(.regularMaterial, in: .rect(cornerRadius: 4))
                    .shadow(color: Color.primary.opacity(0.2), radius: 2)
                    .fixedSize()
                    .font(.body)
            }
    }
}

04:17 We want to align the bottom of the tooltip to the top of the underlying view, so we use alignmentGuide to modify the tooltip's .top alignment value:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                tooltipContent
                    .padding(8)
                    .background(.regularMaterial, in: .rect(cornerRadius: 4))
                    .shadow(color: Color.primary.opacity(0.2), radius: 2)
                    .fixedSize()
                    .font(.body)
                    .alignmentGuide(.top, computeValue: { dimension in
                        dimension[.bottom] + 8
                    })
            }
    }
}

05:09 Rather than calling shadow on the entire view, we could add it to the background directly. That way, we ensure we're not unnecessarily rendering a shadow for the text inside the tooltip view:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                tooltipContent
                    .padding(8)
                    .background(
                        .regularMaterial.shadow(.drop(color: Color.primary.opacity(0.2), radius: 2)),
                        in: .rect(cornerRadius: 4)
                    )
                    .fixedSize()
                    .font(.body)
                    .alignmentGuide(.top, computeValue: { dimension in
                        dimension[.bottom] + 8
                    })
            }
    }
}

Toggling the Tooltip

05:47 The next step would be to add some way to toggle the tooltip, and we can start by putting this logic in the view modifier for now. We add a tap gesture to the view, and when it's triggered, we toggle a local state variable. Then, we check the variable before adding the tooltip to the overlay:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent
    @State private var isShowing = false

    func body(content: Content) -> some View {
        content
            .onTapGesture {
                isShowing.toggle()
            }
            .overlay(alignment: .top) {
                if isShowing {
                    tooltipContent
                        .padding(8)
                        .background(.regularMaterial.shadow(.drop(color: Color.primary.opacity(0.2), radius: 2)), in: .rect(cornerRadius: 4))
                        .fixedSize()
                        .font(.body)
                        .alignmentGuide(.top, computeValue: { dimension in
                            dimension[.bottom] + 8
                        })
                }
            }
    }
}

06:26 Toggling the tooltip now works, but its alignment is broken, because the custom alignment guide doesn't propagate up through the if statement, for some reason. Perhaps this will be fixed soon, but we can work around it by first wrapping the if statement in a stack that stays around unconditionally, and then modifying the alignment of the stack:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent
    @State private var isShowing = false

    func body(content: Content) -> some View {
        content
            .onTapGesture {
                isShowing.toggle()
            }
            .overlay(alignment: .top) {
                ZStack {
                    if isShowing {
                        tooltipContent
                            .padding(8)
                            .background(.regularMaterial.shadow(.drop(color: Color.primary.opacity(0.2), radius: 2)), in: .rect(cornerRadius: 4))
                            .fixedSize()
                            .font(.body)
                    }
                }
                .alignmentGuide(.top, computeValue: { dimension in
                    dimension[.bottom] + 8
                })
            }
    }
}

Multiple Tooltips

07:18 When we add a second tooltip, perhaps on the camera icon, we'll see that the two tooltips overlap each other, which isn't good:

struct ContentView: View {
    var body: some View {
        HStack(spacing: 32) {
            Image(systemName: "house")
                .tooltip {
                    HStack {
                        Text("Home")
                        Circle()
                            .fill(.green)
                            .frame(width: 10, height: 10)
                    }
                }
            Image(systemName: "video")
                .tooltip {
                    HStack {
                        Text("Camera")
                        Circle()
                            .fill(.red)
                            .frame(width: 10, height: 10)
                    }
                }
            Image(systemName: "mic")

        }
        .font(.largeTitle)
        .padding(.vertical, 50)
        .frame(maxHeight: .infinity, alignment: .top)
    }
}

07:44 When one tooltip appears, any other tooltip already onscreen should go away. This sounds like we need something similar to the FocusState API, which allows us to set focus to a certain element, but focus can also be reset automatically. It'd be handy to have something called TooltipState, which gives us a Boolean value that's automatically changed to false when the tooltip is hidden:

struct TooltipModifier<TooltipContent: View>: ViewModifier {
    var tooltipContent: TooltipContent
//    @State private var isShowing = false
    @TooltipState private var isShowing

    // ...
}

08:52 Besides the TooltipState property wrapper, we'll also need some coordinator component at the top level of our app to provide tap targets to hide tooltips.

09:15 But let's first implement the property wrapper, whose wrapped value is a Boolean:

@propertyWrapper
struct TooltipState {
    var wrappedValue: Bool {
        get { false }
        set {
            print("TODO")
        }
    }
}

09:57 Just like State, we need to mark the setter of wrappedValue as non-mutating, so that the view can update the value:

@propertyWrapper
struct TooltipState {
    var wrappedValue: Bool {
        get { false }
        nonmutating set {
            print("TODO")
        }
    }
}

10:11 The code is now compiling, and the property wrapper makes the tooltips hide in our preview, because it always returns false. The next step is to uniquely identify each occurrence of the property wrapper. We might consider using a UUID as an identifier, but that wouldn't provide a stable value across view updates. So instead, we want to use a namespace as the identifier:

@propertyWrapper
struct TooltipState {
    @Namespace private var id
    
    var wrappedValue: Bool {
        get { false }
        nonmutating set {
            print(id)
        }
    }
}

10:51 If we run this in the simulator, we get an error saying we can't use a Namespace property outside of a body view. Also, the printed identifier increments with each click. We can fix both these issues by conforming TooltipState to DynamicProperty:

@propertyWrapper
struct TooltipState: DynamicProperty {
    @Namespace private var id
    
    var wrappedValue: Bool {
        get { false }
        nonmutating set {
            print(id)
        }
    }
}

11:22 This allows us to use any kind of property wrapper, including Namespace, inside our own property wrapper. And now we see the same ID being printed every time we tap the home icon. And we get a different ID if we tap the camera icon.

Centralized State

11:44 Next, we need a way to reset each TooltipState before we show a new tooltip. Since we have to coordinate between multiple tooltips, it sounds like we need some shared state in a common ancestor up the view tree. From that place, we can pass an object, which stores the currently active tooltip, down through the environment.

12:30 This object needs to be Observable, and it can hold on to an optional ID of a tooltip:

@Observable
final class CurrentTooltip {
    var current: Namespace.ID?
}

13:01 In TooltipState, we pick up the object from the environment:

@propertyWrapper
struct TooltipState: DynamicProperty {
    @Namespace private var id
    @Environment(CurrentTooltip.self) private var state
    
    // ...
}

13:08 In the getter of wrappedValue, we compare the local identifier to the object's current value. In the setter, we set current to the identifier if the wrapped Boolean is true. If it's false, it means we're closing the tooltip, and we reset the shared state to nil if it was previously set to our local identifier:

@propertyWrapper
struct TooltipState: DynamicProperty {
    @Namespace private var id
    @Environment(CurrentTooltip.self) private var state
    
    var wrappedValue: Bool {
        get { state.current == id }
        nonmutating set {
            if newValue {
                state.current = id
            } else {
                if state.current == id {
                    state.current = nil
                }
            }
        }
    }
}

13:51 In ContentView, we create an instance of the CurrentTooltip object, and we store it in a state property. Then we pass this object into the environment:

struct ContentView: View {
    @State var current = CurrentTooltip()

    var body: some View {
        HStack(spacing: 32) {
            Image(systemName: "house")
                .tooltip {
                    HStack {
                        Text("Home")
                        Circle()
                            .fill(.green)
                            .frame(width: 10, height: 10)
                    }
                }
            Image(systemName: "video")
                .tooltip {
                    HStack {
                        Text("Camera")
                        Circle()
                            .fill(.red)
                            .frame(width: 10, height: 10)
                    }
                }
            Image(systemName: "mic")

        }
        .font(.largeTitle)
        .padding(.vertical, 50)
        .frame(maxHeight: .infinity, alignment: .top)
        .environment(current)
    }
}

14:29 Now, if we try opening a second tooltip, it automatically closes the first one.

14:43 To clean up, we can move the shared tooltip state and the environment call into their own modifier:

struct TooltipHelper: ViewModifier {
    @State var current = CurrentTooltip()
    
    func body(content: Content) -> some View {
        content
            .environment(current)
    }
}

struct ContentView: View {
    var body: some View {
        HStack(spacing: 32) {
            // ...

        }
        .font(.largeTitle)
        .padding(.vertical, 50)
        .frame(maxHeight: .infinity, alignment: .top)
        .modifier(TooltipHelper())
    }
}

Closing Tooltips

15:33 The last thing we need to do is close a tooltip when we tap anywhere outside the view. And we have to make sure the tap gesture we'll add for this doesn't interfere with any other gestures the view may contain. The safest thing we can do is add a background behind everything else so that we only detect the taps that aren't meant for other interactive elements:

struct TooltipHelper: ViewModifier {
    @State var current = CurrentTooltip()
    
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background {
                Color.clear
                    .onTapGesture {
                        current.current = nil
                    }
            }
            .environment(current)
    }
}

16:18 This almost works, except the tap gesture isn't recognized when we click a completely transparent color. We can fix this by calling contentShape, which lets us define the interactive area of the underlying view:

struct TooltipHelper: ViewModifier {
    @State var current = CurrentTooltip()
    
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background {
                Color.clear
                    .contentShape(.rect)
                    .onTapGesture {
                        current.current = nil
                    }
            }
            .environment(current)
    }
}

16:42 Our tooltip is working well so far. Next time, we can take care of positioning the tooltip. Right now, we're always placing the tooltip just above the source view, with some padding, but we should check if there's actually room for it there.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

150 Episodes · 52h37min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes