Adding tips to the application using TipKit

When TipKit was first mentioned during a keynote at WWDC 2023, I initially assumed it was some kind of new way to display apps in the Tips app and perhaps Spotlight. Instead, we saw a built-in component for adding small learning views to our own apps on all platforms, with a rules system for condition-based display and syncing across multiple devices via iCloud! Moreover, Apple itself uses this component in iOS 17, for example, in the Messages and Photos applications.

Having developed several tooltip messaging systems in the past, I was eagerly awaiting the announcement of this functionality at WWDC 2023. I was somewhat disappointed when beta after beta of Xcode skipped the TipKit framework, but luckily Xcode 15 beta 5 ( published the day before this article was published) it finally appeared along with the corresponding documentationit, allowing us to integrate tips into our own applications.

Before I show you how TipKit works and how it can be implemented in our applications, here is a very important tip that Ellie Gattozzi gave in the talk “DWe want features to be discoverable using TipKit” at WWDC 2023:

Helpful tooltips include direct phrases as headlines that tell you what the feature is, and messages with easy-to-remember benefits or instructions so that users know why they should use the feature and can then use it themselves.

So let’s create our first clue!

Note: I’ve provided the code below for SwiftUI and UIKit, but Apple has also provided a way to display tooltips in AppKit. It should be noted that versions of UIKit are not available for watchOS and tvOS. It’s also worth noting that TipKit beta 5 has a few bugs, particularly related to the steps I’ve outlined below.

1. Create a tooltip

First, we need to initiate the Tips system when we launch our application using the function Tips.configure()1:

// SwiftUI
var body: some Scene {
    WindowGroup {
        ContentView()
        .task {
            try? await Tips.configure()
        }
    }
}

// UIKit
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Task {
        try? await Tips.configure()
    }
    return true
}

Next we create a structure that defines our tooltip:

struct SearchTip: Tip {
    var title: Text {
        Text("Add a new game")
    }
    
    var message: Text? {
        Text("Search for new games to play via IGDB.")
    }
    
    var asset: Image? {
        Image(systemName: "magnifyingglass")
    }
}

Finally, we display our tooltip on the screen:

// SwiftUI
ExampleView()
    .toolbar(content: {
        ToolbarItem(placement: .primaryAction) {
            Button {
                displayingSearch = true
            } label: {
                Image(systemName: "magnifyingglass")
            }
            .popoverTip(SearchTip())
        }
    })


// UIKit
class ExampleViewController: UIViewController {
    var searchButton: UIButton
    var searchTip = SearchTip()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Task { @MainActor in
            for await shouldDisplay in searchTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let controller = TipUIPopoverViewController(searchTip, sourceItem: searchButton)
                    present(controller)
                } else if presentedViewController is TipUIPopoverViewController {
                    dismiss(animated: true)
                }
            }
        }
    }
}

This code is all that is required to display our tooltip when the view first appears:

A popover tip using TipKit

Tooltip implemented using TipKit

There are two types of tooltip views:

  • Popover: Displays as an overlay on the app’s UI, allowing users to be targeted without changing the view.

  • In-line: Temporarily rearranges the app’s UI around itself so that nothing is occluded (not available on tvOS)

If we wanted to display an inline tooltip, our code would look like this:

// SwiftUI
VStack {
    TipView(LongPressTip())
}

// UIKit
class ExampleViewController: UIViewController {
    var longPressGameTip = LongPressGameTip()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Task { @MainActor in
            for await shouldDisplay in longPressGameTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let tipView = TipUIView(longPressGameTip)
                    view.addSubview(tipView)
                } else if let tipView = view.subviews.first(where: { $0 is TipUIView }) {
                    tipView.removeFromSuperview()
                }
            }
        }
    }
}
An in-line tip using TipKit

Inline tooltip implemented using TipKit

UIKit also has a class TipUICollectionViewCell to display tooltips in the collection view, which can also be used in tabular interfaces. SwiftUI code is definitely less verbose.

2. Customization of tooltips

You can customize tooltips by changing text and font colors, background color, corner radius, and icons. Tooltip views are also fully dark theme compatible.

Fonts and text color

This is configurable in the Tip structures themselves, since you return SwiftUI.Text instances even if you end up displaying the tip in UIKit or AppKit.

struct LongPressTip: Tip {
    var title: Text {
        Text("Add to list")
            .foregroundStyle(.white)
            .font(.title)
            .fontDesign(.serif)
            .bold()
    }
    
    var message: Text? {
        Text("Long press on a game to add it to a list.")
            .foregroundStyle(.white)
            .fontDesign(.monospaced)
    }
    
    var asset: Image? {
        Image(systemName: "hand.point.up.left")
    }
}

Because both the header and the message use Textyou can use any modifiers that return an instance Textsuch as foregroundStyle, fontas well as all sorts of convenient methods like bold(). The icon returns as Imageso if we want to change anything, like the color of an icon, we have to do it in the view itself Tip:

Icon color, background color and close button color

// SwiftUI
TipView(LongPressGameTip())
    .tipBackground(.black)
    .tint(.yellow)
    .foregroundStyle(.white)

// UIKit
let tipView = TipUIView(LongPressGameTip())
tipView.backgroundColor = .black
tipView.tintColor = .yellow

To change the background color of the tooltip there is a special method, to change the color of the icon you need to use a global tint, and the color of the close button depends on foregroundStyle; Note that this button is 50% opaque, so if you’re using a dark background you’ll be hard-pressed to see anything other than white. There doesn’t seem to be a way in UIKit to change the color of this button.

Although there are no Human Interface Guidelines for tips yet, viewing the iOS 17 beta and performances at WWDC 2023 shows that Apple uses blank SF Symbols for all of its tooltips. For this reason, I recommend that you do the same!

Corner radius

// SwiftUI
TipView(LongPressGameTip())
    .tipCornerRadius(8)

The default corner radius for tooltips in iOS is 13. If you want to change this to match other rounded elements in your app, you can do so using the function tipCornerRadius() in SwiftUI. There is no way in UIKit to change the corner radius for tooltip views.

A customized tip view with new colors and fonts

Customized tooltip presentation with new colors and fonts. I know how ugly it looks.

I was pleasantly surprised by how flexible the design of the first version of TipKit was. However, I would be careful not to customize the tooltips too much, since their similarity to the default system tooltips will undoubtedly have a positive effect on usability.

3. Straight to action!

Tooltips allow you to add several buttons, called actions, that can be used to navigate to a related setting or more detailed guidance. This feature is not available on tvOS.

To add an action, you first need to configure the Tip structure by adding some identifying information to it:

// SwiftUI
struct LongPressGameTip: Tip {
    
    // [...] заголовок, сообщение, ассет
    
    var actions: [Action] {
        [Action(id: "learn-more", title: "Learn More")]
    }
}

Please note that the initializer Action also has allows you to use the block Textbut not Stringwhich allows you to make all those color and font adjustments discussed earlier.

An action button within a Tip View

Action button in tooltip view

We can then change the tooltip view so that it performs an action when the button is clicked:

// SwiftUI
Button {
    displayingSearch = true
} label: {
    Image(systemName: "magnifyingglass")
}
    .popoverTip(LongPressGameTip()) { action in
        guard action.id == "learn-more" else { return }
        displayingLearnMore = true
    }

// UIKit
let tipView = TipUIView(LongPressGameTip()) { action in
    guard action.id == "learn-more" else { return }
    let controller = TutorialViewController()
    self.present(controller, animated: true)
}

As an alternative, we can add action handlers directly to the Tip structure:

var actions: [Action] {
    [Action(id: "learn-more", title: "Learn More", perform: {
        print("'Learn More' pressed")
    })]
}

Important: Although you can add actions in Xcode 15 beta 5, handlers do not fire when a button is clicked, regardless of whether you use a framework or a view to connect them.

One last thing to note about actions is that they can be disabled if for some reason you do not want to perform them (for example, if the user is not logged in or has not subscribed to premium features):

var actions: [Action] {
    [Action(id: "pro-feature", title: "Add a new list", disabled: true)]
}

4. Announce the rules

By default, tooltips appear as soon as the view to which they are attached appears on the screen. However, you might not show a tooltip in a particular view until some condition is met (for example, the user must be logged in), or you might want the user to interact with a feature a certain number of times before the tooltip appears. Luckily, Apple thought about this and added a concept known as “rules” that allows you to limit the appearance of tooltips.

There are two types of rules:

  • Parametric: Are constant and mostly map to Swift’s Boolean value types

  • Event-Based: Defines an action that must be performed before the tooltip is allowed to be displayed

Important: There is a bug in Xcode 15 beta 5 that prevents the @Parameter macro from compiling on simulators or macOS apps. As a workaround, you can add the following value to the “Other Swift Flags” build setting:

-external-plugin-path $(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins#$(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server

Parametric rules

struct LongPressGameTip: Tip {
    
    @Parameter
    static var isLoggedIn: Bool = false
    
    var rules: [Rule] {
        #Rule(Self.$isLoggedIn) { $0 == true }
    }
    
    // [...] заголовок, сообщение, актив, действия и т.д.
    
}

Thanks to new support for macros in Xcode 15, the syntax is relatively simple. First we define a static variable for the condition, in this case a boolean, that determines whether the user is logged in to the system. We then define a rule based on the truth of this condition.

If we run our application now, the tooltip will no longer be displayed on startup. However, if we mark a static property as true, then a tooltip will appear the next time the corresponding view is displayed:

LongPressGameTip.isLoggedIn = true

Event rules

struct LongPressGameTip: Tip {
    
    static let appOpenedCount = Event(id: "appOpenedCount")
        
    var rules: [Rule] {
        #Rule(Self.appOpenedCount) { $0.donations.count >= 3 }
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

The rules bound to events are somewhat different in that instead of a parameter we use an object Event with the identifier we have chosen. The rule then checks the property donations this event to determine (in our specific example) whether the application has been opened three or more times. In order for this rule to work, we need to be able to “donate” when this event occurs. For this we use the method donate in the event itself:

SomeView()
    .onAppear() {
        LongPressTip.appOpenedCount.donate()
    }

Property donation in the event contains a property datewhich is set at the time the event was called donate. This means we can add a rule to check if the user has opened the application three or more times:

struct LongPressGameTip: Tip {
    
    static let appOpenedCount: Event = Event(id: "appOpenedCount")
        
    var rules: [Rule] {
        #Rule(Self.appOpenedCount) {
            $0.donations.filter {
                Calendar.current.isDateInToday($0.date)
            }
            .count >= 3
        }
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Important: Even though this code should work according to the WWDC 2023 report, when running in Xcode 15 beta 5 it throws the error “the filter function is not supported in this rule”.

5. To display or not to display?

While rules may limit tooltips to appear when we want them to, there is always the possibility that multiple tooltips may try to appear simultaneously. It may also be that we no longer want to show the tooltip if the user started interacting with our function before the tooltip was shown. To solve this problem, Apple provides us with ways to control the frequency, number of displays, and override prompts. There is also a mechanism for synchronizing the display status of tooltips on multiple devices.

Frequency

By default, tooltips are shown as soon as they are allowed to do so. We can change this by setting the parameter DisplayFrequency when initializing Tips when starting the application:

try? await Tips.configure(options: {
    DisplayFrequency(.daily)
})

This code sets the limit to show one tooltip per day.

There are several predefined values DisplayFrequencysuch as .daily And .hourlybut you can also specify TimeInterval, if you need some custom value. Alternatively, you can always restore the default behavior using the value .immediate.

If you have set a non-instant display frequency, but you have a tooltip that you want to display immediately, you can do this using the option IgnoresDisplayFrequency() in the structure Tip:

struct LongPressGameTip: Tip {
    
    var options: [TipOption] {
        [Tip.IgnoresDisplayFrequency(true)]
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Display Counter

If the prompt is not manually canceled by the user, it will be shown again the next time the corresponding view appears, even after the application is launched. To avoid showing the prompt to the user again, you can set the value MaxDisplayCountwhich will limit the number of impressions after which the tooltip will no longer be displayed:

struct LongPressGameTip: Tip {
    
    var options: [TipOption] {
        [Tip.MaxDisplayCount(3)]
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Cancel a hint

Depending on our rules and frequency of display, it may happen that the user interacts with the function before our tooltip has been shown. In this case, we want to invalidate our tooltip so that it doesn’t show up later:

longPressGameTip.invalidate(reason: .userPerformedAction)

There are three possible reasons for a tip to be invalidated:

  • maxDisplayCountExceeded

  • userClosedTip

  • userPerformedAction

The first two actions are performed by the system depending on who caused the prompt to be cancelled: the display count counter or the user. This means that when canceling hints you should always use .userPerformedAction.

Sync with iCloud

In the report “Making Features Discoverable Using TipKit“Charlie Parks mentions:

TipKit can also sync tip status via iCloud to ensure that tips shown on one device won’t be displayed on another. For example, if an app is installed on an iPad and an iPhone, and its functionality is identical on both devices, then it is probably better not to inform the user about the same feature on both devices.

This feature is enabled by default with no option to disable it, so you will have to provide custom IDs for each tooltip on supported platforms if you want the tooltips to re-appear on each device for some reason (for example, if the user interface differs significantly across devices ).

6. Debugging

TipKit provides a convenient API for testing, allowing you to show or hide tooltips as needed, test all tooltips without following their rules, or clear all information in TipKit’s data store to return the app’s original build state.

// Показать все определенные в приложении подсказки
Tips.showAllTips()

// Показать указанные подсказки
Tips.showTips([searchTip, longPressGameTip])

// Скрыть указанные подсказки
Tips.hideTips([searchTip, longPressGameTip])

// Скрыть все подсказки, определенные в приложении
Tips.hideAllTips()

If we want to clear all data associated with TipKit, we need to use the modifier DatastoreLocation when initializing the framework Tips when starting the application:

try? await Tips.configure(options: {
    DatastoreLocation(.applicationDefault, shouldReset: true)
})

Conclusion

A tip displayed on tvOS

Tooltip displayed in my “Chaise Longue to 5K” app for tvOS

Hints help users discover features in your app on iOS, iPadOS, macOS, watchOS, or tvOS. Remember to keep tooltips concise, educational, and actionable, and use rules, frequency, and cancellation to ensure tooltips are shown only when needed.


In conclusion, we invite everyone to a two-day master class “Writing an iOS application using KMP + Compose”, which will be held on November 20 and 21 as part of the online course “iOS Developer. Professional”. Register: Day 1, Day 2.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *