Suggesting features to users with TipKit

AsyncLearn
7 min readOct 7, 2023

WWDC 2023 brought many surprises, one of them being the introduction of TipKit, a framework that allows displaying suggestions or tips to users of an app. Some use cases may include making existing but lesser-known features more visible or showcasing new features introduced in an app update.

TipKit is available starting from devices with iOS 17, iPadOS 17, macOS 14, watchOS 10, and tvOS 17.

Example app in SwiftUI

As a demonstration, create an iOS project with SwiftUI in Xcode 15 and replace the content of the ContentView.swift file with the following code:

import SwiftUI

struct ContentView: View {
@State var number = 0

var body: some View {
VStack {
Text("\(number)")
.onTapGesture { number += 1 }
.font(.title)
}
.padding()
}
}

#Preview {
ContentView()
}

This code defines a SwiftUI view that counts the number of times the text is pressed. It uses a @State variable number to store the count of times the text is pressed and displays the number in a title format.

When running the project, the example app should look as shown below:

SwifUI app example

As you can see, this view doesn’t clearly communicate the functionality of tapping the text to the user. In this article, you’ll learn how to effectively use TipKit to suggest this functionality.

Enabling TipKit

Before creating your first tip, you need to enable TipKit at the launch of your app. To do this, open the structure that conforms to the App protocol, add import TipKit, and the following code after the definition of the main view within the WindowGroup:

.task {
try? Tips.configure()
}

Tips.configure(_:) loads and configures the states of all the tips in the app.

Another way to enable TipKit is by creating an initializer in the structure that conforms to the App protocol:

init() {
try? Tips.configure()
}

For the purpose of this article, add try? Tips.resetDatastore() before try? Tips.configure(_:) to reset all tips to their initial state and allow showing the tips every time you run the app on the simulator or device. Your code should look like this:

.task {
try? Tips.resetDatastore()
try? Tips.configure()
}

If you want to test the tips from the Xcode Canvas, add the above code inside the #Preview of the ContentView, for example:

#Preview {
ContentView()
.task {
try? Tips.resetDatastore()
try? Tips.configure()
}
}

Remember to remove try? Tips.resetDatastore() before publishing your app in production.

Creating a Tip

To create your first tip, you need to create a structure that implements the Tip protocol. This protocol defines the content of your tip. Next, create a structure named CountTip and add the following code:

import TipKit

struct CountTip: Tip {
// 1
var title: Text {
Text("Press the text to count")
}

// 2
var message: Text? {
Text("The text will change when you tap it.")
}

// 3
var image: Image? {
Image(systemName: "hand.tap.fill")
}
}
  1. title: The title that will be displayed in the tip.
  2. message: The message that will be displayed in the tip.
  3. image: The image that will be displayed in the tip.

These properties take SwiftUI views, so you can use modifiers to customize them to your preferences, such as the .foregroundStyle modifier to change the color.

Adding a Tip to a View

The first thing you should do is import TipKit into ContentView with import TipKit, and then create an instance of the CountTip tip:

var countTip = CountTip()

With the instance created, there are two ways to display a tip in a SwiftUI view: Inline and Popover.

Inline Tip

It is displayed within the current view with a card or bubble-like design, respecting the elements of the user interface already added. To add an Inline Tip above the Text, use:

TipView(countTip)

When running the app, you will see the Inline Tip with the title, message, and image you defined in CountTip, along with a close button with an X.

TipView has an arrowEdge parameter that allows you to display an arrow on the side of your choice. For example, if you want an arrow pointing downward, use:

TipView(countTip, arrowEdge: .bottom)
Inline TipView with a bottom arrow edge

To modify the corner radius of the TipView, you can use the .tipCornerRadius(_:antialiased:) modifier. For example:

TipView(countTip)
.tipCornerRadius(100)
Inline TipView with custom corner radius

Popover Tip

Use the .popoverTip(_:arrowEdge:action:) modifier to display the tip above the main view and anchored to the element that has this modifier.

To avoid showing the same tip twice, remove the inline tip TipView and replace the Text with the following code:

Text("\(number)")
.onTapGesture { number += 1 }
.font(.title)
.popoverTip(countTip)

When running the app, you will see the tip anchored to the text and a slight shadow to indicate to the user that the tip is on top of the main view.

Popover TipView

By default, an arrow pointing to the anchored view is included. If you want to modify it, as with TipView, use the arrowEdge parameter. This will also change the position of the tip. For example, if you want the arrow to point downward:

.popoverTip(countTip, arrowEdge: .bottom)

From this section onward, what you learn applies to both ways of displaying a tip, so use the one you prefer, whether TipView or .popoverTip(_:arrowEdge:action:).

Manually Closing a Tip

In addition to closing the tip by pressing the X button, you can do so manually using the invalidate(reason:) function, providing a reason to describe why the tip was invalidated. Reasons can be:

  • actionPerformed: Indicates that the action described in the tip has been performed.
  • displayCountExceeded: Indicates that the tip has been displayed more times than allowed.
  • tipClosed: Indicates that the user closed the tip while it was displayed.

For example, if you want to close the tip when the user taps the Text, add the following inside its .onTapGesture modifier:

countTip.invalidate(reason: .actionPerformed)
Manually invalidate a tip

Adding Buttons to the Tip

It’s straightforward to add buttons to your tips, as the Tip protocol includes the var actions: [Action] variable, which requires a list of Tips.Actions.

Tips.Actions has two initializers to create a button: init(id:perform:_:) and init(id:title:perform:). These allow you to:

  • Add an optional identifier in the id parameter.
  • Use a String or Text as the button's title.
  • Provide a closure in the perform parameter to specify the action to be taken when the user presses the button.

Open the CountTip.swift structure and insert the following code after the image variable:

var actions: [Action] {
// 1
Action(id: "action-ok", title: "Ok")
// 2
Action {
invalidate(reason: .tipClosed)
} _: {
Text("Close")
.foregroundStyle(.red)
}
}
  1. An Action with the identifier "action-ok" and the text "Ok."
  2. An Action that, when pressed, will close the tip and display the "Close" text in red using Text and the foregroundStyle modifier.
Tip with actions

Another way to handle the button’s action from the SwiftUI view is by using the action parameter in TipView or the .popoverTip modifier. This is simply a closure that contains the selected Action.

If you are using TipView, replace the view with the following code:

TipView(countTip) { action in
if action.id == "action-ok" {
countTip.invalidate(reason: .tipClosed)
}
}

If you are using .popoverTip, replace the modifier with the following code:

.popoverTip(countTip) { action in
if action.id == "action-ok" {
countTip.invalidate(reason: .tipClosed)
}
}

In both cases, the id of the action that the user pressed is checked, and the tip is closed accordingly.

Other Configurations

With Tips.configure(_:), you can add global configurations to all the tips in your app. These configurations include .displayFrequency(_:) and .datastoreLocation(_:).

  • .displayFrequency(_:) defines how often the tips will be shown. They can be shown all at once or one at a time every hour, day, week, or month.
  • .datastoreLocation(_:) defines where the tip data will be stored. It can be stored in the app's default directory, a group container, or a specific URL.
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])

In this example, the tips are configured to be shown immediately and stored in the app’s default directory.

Other configurations that you can apply to a specific tip include setting the maximum number of times it will be shown before it is automatically invalidated with MaxDisplayCount or ignoring the display frequency with IgnoresDisplayFrequency. These two options are used within the options: [TipOption] property of the Tip protocol. For example:

To show the tip only 2 times:

var options: [TipOption] {
MaxDisplayCount(2)
}

To ignore the display frequency of the tip:

var options: [TipOption] {
IgnoresDisplayFrequency(true)
}

These two options override the global configuration set with .displayFrequency(_:) for the tip in which they are specified.

Finally, in addition to Tips.resetDatastore(), for testing purposes, you can use:

  • try? Tips.showAllTipsForTesting(_:): to show all or some tips.
  • try? Tips.hideTipsForTesting(_:): to hide all or some tips.

Remember that these last two should be placed before Tips.configure(_:).

Conclusions

Apple recommends that all tips be actionable, instructive, and easy to remember. Additionally, TipKit allows you to synchronize the state of a tip through iCloud to ensure that a tip seen on one device is not displayed on others.

Don’t forget to share your tips with us on our social media! 😉

If you want to read the Spanish version of this article, you can find it here: https://asynclearn.com/blog/sugerir-funcionalidades-tipkit/

--

--

AsyncLearn

Stay up-to-date in the world of mobile applications with our specialised blog.