Mastering TipKit: Basics

fatbobman ( 东坡肘子)
ITNEXT
Published in
16 min readOct 17, 2023

--

Photo by Sam Dan Truong on Unsplash

TipKit is a framework introduced by Apple at WWDC 2023 that allows you to easily display tips in your applications. It can be used to introduce new features to users, help them discover hidden options, or demonstrate faster ways to accomplish tasks, among other scenarios. TipKit is compatible with different hardware environments and operating systems within the Apple ecosystem, including iPhone, iPad, Mac, Apple Watch, and Apple TV.

Developers can not only control the timing and frequency of tip display through rules and display frequency strategies, but also obtain information about the status of tips and events associated with them through the API. Although TipKit is primarily created for displaying tips, its functionality is not limited to that.

I will explore the TipKit framework in two articles. In this article, we will first learn about the usage of TipKit. In the next article, we will discuss more usage tips, considerations, implementation principles, and other extended topics of using TipKit in different scenarios.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

How to Define a Tip

In TipKit, defining a Tip means declaring a struct that conforms to the Tip protocol. The Tip protocol defines the title, image, information for displaying a Tip, as well as the rules for determining whether the display conditions are met.

struct InlineTip: Tip {
var title: Text {
Text("Save as a Favorite")
}
var message: Text? {
Text("Your favorite backyards always appear at the top of the list.")
}
var image: Image? {
Image(systemName: "star")
}
}

Let the tip achieve the desired effect.

The tips shown in the image below are recommended because they have operability, guidance, and easy memorization.

Here are the types of information that are not suitable for displaying as tips:

  • Promotional information
  • Error messages
  • Non-actionable information
  • Information that is too complex to be immediately readable

Initialize Tip container

To make the TipKit framework work in the application, you need to execute the configuration command of the Tip container before the first Tip appears, usually in the initial stage of the application. For example:

import TipKit

@main
struct TipKitExamplesApp: App {
init() {
// Configure Tip's data container
try? Tips.configure()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Tips.configure is used to initialize the data container. In it, TipKit stores tips and their associated event information. It also supports adjusting the global display frequency strategy of tips through parameters (detailed below).

Adding Tip in SwiftUI Views

TipKit provides two ways to display tips: inline (TipView) and pop-up window (popoverTip).

Apple provides a Demo that showcases various Tip functionalities. This article utilizes some of the code provided by the Demo.

Inline

Using the TipView provided by TipKit, you can add tips inline within your views. Apple recommends using this style to display tips in order to avoid covering the content that people may want to see and interact with UI elements.

struct InlineView: View {
// Create an instance of your tip content.
var tip = InlineTip()

var body: some View {
VStack(spacing: 20) {
Text("A TipView embeds itself directly in the view. Make this style of tip your first choice as it doesn't obscure or hide any underlying UI elements.")
// Place your tip near the feature you want to highlight.
TipView(tip, arrowEdge: .bottom)
Button {
// Invalidate the tip when someone uses the feature.
tip.invalidate(reason: .actionPerformed)
} label: {
Label("Favorite", systemImage: "star")
}
Text("To dismiss the tip, tap the close button in the upper right-hand corner of the tip or tap the Favorite button to use the feature, which then invalidates the tip programmatically.")
Spacer()
}
.padding()
.navigationTitle("TipView")
}
}

In the above code, we first create an InlineTip instance in the view, and then place the TipView in the desired position where the Tip should appear. Developers can set the direction of the arrow by using the arrowEdge parameter. When set to nil, the arrow will not be displayed.

TipView is no different from other SwiftUI views. It participates in the layout in the same way as standard SwiftUI views and has an impact on the existing layout when displayed. In other words, developers can place it in any layout container and apply various view modifiers to it.

TipView(tip)
.frame(width:250)
.symbolRenderingMode(.multicolor)

Popup Window

Using the popoverTip view decorator, display the Tip in the view as a top-level view.

struct PopoverTip: Tip {
var title: Text {
Text("Add an Effect")
.foregroundStyle(.indigo)
}
var message: Text? {
Text("Touch and hold \(Image(systemName: "wand.and.stars")) to add an effect to your favorite image.")
}
}

struct PopoverView: View {
// Create an instance of your tip content.
var tip = PopoverTip()
var body: some View {
VStack(spacing: 20) {
....
Image(systemName: "wand.and.stars")
.imageScale(.large)
// Add the popover to the feature you want to highlight.
.popoverTip(tip)
.onTapGesture {
// Invalidate the tip when someone uses the feature.
tip.invalidate(reason: .actionPerformed)
}
....
}
}
}

You can adjust the placement of the Tip relative to the view it is applied to by using arrowEdge. It cannot be set to nil:

.popoverTip(tip,arrowEdge: .leading)

In iOS, pop-up windows will be presented as modal views, and interaction with other elements can only be done after closing or hiding the tip. Additionally, developers cannot apply view modifiers to the tip view popped up through popoverTip.

How to adjust the appearance of Tip

For the TipView and popoverTip provided by TipKit, we can adjust their display effects in the following ways:

Apply modifiers to Text and Image without changing their types

In order to improve the display effect of text and images without breaking the Text and Image types, we can use appropriate modifiers. For example:

struct InlineTip: Tip {
var title: Text {
Text("Save \(Image(systemName: "book.closed.fill")) as a Favorite")
}
var message: Text? {
Text("Your ") +
Text("favorite")
.bold()
.foregroundStyle(.red) +
Text(" backyards always appear at the \(Text("top").textScale(.secondary)) of the list.")
}
var image: Image? {
Image(systemName: "externaldrive.fill.badge.icloud")
.symbolRenderingMode(.multicolor)
}
}

This method is effective for both TipView and popoverTip views.

Using the modifiers unique to TipView

TipView(tip,arrowEdge: .bottom)
.tipImageSize(.init(width: 30, height: 30))
.tipCornerRadius(0)
.tipBackground(.red)

This method only applies to TipView.

You can combine unique modifiers, standard view modifiers, and Text and Image with additional information together.

Using TipViewStyle to customize the appearance of TipView

Just like many other SwiftUI components, TipKit also provides the ability to customize the appearance of TipViewthrough styles.

struct MyTipStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
VStack {
if let image = configuration.image {
image
.font(.title2)
.foregroundStyle(.green)
}
if let title = configuration.title {
title
.bold()
.font(.headline)
.textCase(.uppercase)
}
if let message = configuration.message {
message
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
.backgroundStyle(.thinMaterial)
.overlay(alignment: .topTrailing) {
// Close Button
Image(systemName: "multiply")
.font(.title2)
.alignmentGuide(.top) { $0[.top] - 5 }
.alignmentGuide(.trailing) { $0[.trailing] + 5 }
.foregroundStyle(.secondary)
.onTapGesture {
// Invalidate Reason
configuration.tip.invalidate(reason: .tipClosed)
}
}
.padding()
}
}

TipView(tip, arrowEdge: .bottom)
.tipViewStyle(MyTipStyle())

Developers can choose not to add a close button in the custom style to prevent users from disabling the prompt through that means.

Additionally, developers can completely abandon TipView and popoverTip and achieve complete control over the way Tips are displayed by responding to the Tip state (which will be detailed in the next article).

Adding Action Button to Tip

So far, the Tips we have created are purely informational. By adding actions, we can make Tips more interactive and enable more functionality.

struct PasswordTip: Tip {
var title: Text {
Text("Need Help?")
}
var message: Text? {
Text("Do you need help logging in to your account?")
}
var image: Image? {
Image(systemName: "lock.shield")
}
var actions: [Action] {
// Define a reset password button.
Action(id: "reset-password", title: "Reset Password")
// Define a FAQ button.
Action(id: "faq", title: "View our FAQ")
}
}

// In View
struct PasswordResetView: View {
@Environment(\.openURL) private var openURL
// Create an instance of your tip content.
private var tip = PasswordTip()
var body: some View {
VStack(spacing: 20) {
Text("Use action buttons to link to more options. In this example, two actions buttons are provided. One takes the user to the Reset Password feature. The other sends them to an FAQ page.")
// Place your tip near the feature you want to highlight.
TipView(tip, arrowEdge: .bottom) { action in
// Define the closure that executes when someone presses the reset button.
if action.id == "reset-password", let url = URL(string: "<https://iforgot.apple.com>") {
openURL(url) { accepted in
print(accepted ? "Success Reset" : "Failure")
}
}
// Define the closure that executes when someone presses the FAQ button.
if action.id == "faq", let url = URL(string: "<https://appleid.apple.com/faq>") {
openURL(url) { accepted in
print(accepted ? "Success FAQ" : "Failure")
}
}
}
Button("Login") {}
Spacer()
}
.padding()
.navigationTitle("Password reset")
}
}

In the above code, we first add the actions in PasswordTip. The id is used to identify different Action sources in the callback closure.

var actions: [Action] {
Action(id: "reset-password", title: "Reset Password")
Action(id: "faq", title: "View our FAQ")
}

In the Tip protocol, the definition of actions is @Tips.OptionsBuilder var options: [TipOption] { get }, which is a Result builder. Therefore, it can be synthesized and returned as an array of Actions using the above method.

In the view, determine the source of the Action by adding a closure after TipView, and implement the corresponding operation.

TipView(tip, arrowEdge: .bottom) { action in
if action.id == "reset-password", let url = URL(string: "<https://iforgot.apple.com>") {
openURL(url) { accepted in
print(accepted ? "Success Reset" : "Failure")
}
}
if action.id == "faq", let url = URL(string: "<https://appleid.apple.com/faq>") {
openURL(url) { accepted in
print(accepted ? "Success FAQ" : "Failure")
}
}
}

popoverTip also provides a version that supports actions.

.popoverTip(tip){ action in
// ....
}

In this example, the implementation of the Action’s operation is done within the view because it requires the openURLprovided by the view's environment value. If the information from the view is not needed, the corresponding operation code can be directly added in the definition of the Action.

Action(id: "faq", title: "View our FAQ", perform: {
if let url = URL(string: "<https://appleid.apple.com/faq>") {
UIApplication.shared.open(url)
}
})

TipView(tip, arrowEdge: .bottom)

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

To establish display rules for Tips

If it’s only for providing the Tip view template mentioned in the previous context, there is absolutely no need for Apple to create the TipKit framework. The power of the TipKit framework lies in the fact that developers can create separate rules for each Tip and apply those rules to determine whether the Tip should be displayed.

Rules are used to determine the basis for displaying or not displaying tip based on certain states (parameters) or user events, so we first need to define the required parameters and events in the Tip struct.

Define parameters for Tip

We can define a variable in the Tip structure using the @Parameter macro to represent the application state to be tracked.

struct ParameterRuleTip: Tip {
// Define the app state you want to track.
@Parameter
static var isLoggedIn: Bool = false
}

Please note that the defined state is a static property, which is shared by all instances of this structure.

By expanding the macro, we can see the complete code generated by @Parameter:

static var $isLoggedIn: Tips.Parameter<Bool> = Tips.Parameter(Self.self, "isLoggedIn", false)
static var isLoggedIn: Bool = false
{
get {
$isLoggedIn.wrappedValue
}

set {
$isLoggedIn.wrappedValue = newValue
}
}

The type of $isLoggedIn is Tips.Parameter<Bool>, which provides the ability to persist the value of ParameterRuleTip.isLoggedIn.

TipKit provides a @Parameter(.transient) option for @Parameter. When enabled, TipKit will use the default value provided in the Tip definition instead of the persisted value when the application restarts. This is slightly different from the transient option in Core Data or SwiftData, as in TipKit, even when the transient option is enabled, the data will still be persisted. This is mainly to facilitate dynamic synchronization of this parameter between different applications and components that use the same TipKit data source.

Create a rule that determines whether to display Tips based on status

Now, we can use the previously defined isLoggedIn property to create rules to determine if the conditions for displaying the ParameterRuleTip are met.

struct ParameterRuleTip: Tip {
// Define the app state you want to track.
@Parameter
static var isLoggedIn: Bool = false

var rules: [Rule] {
[
// Define a rule based on the app state.
#Rule(Self.$isLoggedIn) {
// Set the conditions for when the tip displays.
$0 == true
}
]
}
// ...
}

#Rule(Self.$isLoggedIn) indicates that this rule will observe the isLoggedIn property and pass isLoggedIn as a parameter to the closure.

#Rule is also a macro, and when expanded, it can be seen that TipKit's rules are built on Predicates.

Tip.Rule(Self.$isLoggedIn) {
PredicateExpressions.build_Equal(
lhs: PredicateExpressions.build_Arg($0),
rhs: PredicateExpressions.build_Arg(true)
)
}

In the view, we can show or hide the Tip by modifying the value of isLoggedIn.

struct ParameterView: View {
// Create an instance of your tip content.
private var tip = ParameterRuleTip()

var body: some View {
VStack(spacing: 20) {
Text("Use the parameter property wrapper and rules to track app state and control where and when your tip appears.")
// Place your tip near the feature you want to highlight.
TipView(tip, arrowEdge: .bottom)
Image(systemName: "photo.on.rectangle")
.imageScale(.large)
Button("Tap") {
// Trigger a change in app state to make the tip appear or disappear.
ParameterRuleTip.isLoggedIn.toggle()
}
Text("Tap the button to toggle the app state and display the tip accordingly.")
Spacer()
}
.padding()
.navigationTitle("Parameters")
}
}

In the above code, for the sake of demonstration, we modify the value of isLoggedIn by clicking a button. Of course, we can also pass the value change through a constructor, like:

struct ParameterRuleTip: Tip {
init(isLoggedIn:Bool){
Self.isLoggedIn = isLoggedIn
}
....
}

struct ParameterView: View {
private var tip: ParameterRuleTip
init(isLoggedIn: Bool) {
tip = ParameterRuleTip(isLoggedIn: isLoggedIn)
}
....
}

Actually, developers can read or set the value of ParameterRuleTip.$isLoggedIn anywhere in the application using ParameterRuleTip.isLoggedIn, regardless of whether it is in the view. TipKit will observe the changes of this value to determine whether to display ParameterRuleTip.

The state of ParameterRuleTip.isLoggedIn can only be observed in real-time by TipKit and cannot be used as a source of truth for SwiftUI views.

Define events for Tip

Besides determining whether to display a Tip by observing a specific state, TipKit also provides another method of creating rules using statistical analysis.

First, we need to define an event for Tip, and then determine whether to display Tip based on the quantity and frequency of that event.

struct EventRuleTip: Tip {
// Define the user interaction you want to track.
static let didTriggerControlEvent = Event(id: "didTriggerControlEvent")
....

var rules: [Rule] {
[
// Define a rule based on the user-interaction state.
#Rule(Self.didTriggerControlEvent) {
// Set the conditions for when the tip displays.
$0.donations.count >= 3
}
]
}
}

Just like parameters, events are also static properties. id is the identifier of the event.

The meaning of the following rule is that the EventRuleTip will only be displayed after the didTriggerControlEventevent has been triggered at least three times.

#Rule(Self.didTriggerControlEvent) {
// Set the conditions for when the tip displays.
$0.donations.count >= 3
}

We can generate events anywhere in the application using TipTypeName.EventProperty.donate() . TipKit records the time of each event generation and uses it as a basis for judging and filtering.

struct EventView: View {
// Create an instance of your tip content.
private var tip = EventRuleTip()

var body: some View {
VStack(spacing: 20) {
Text("Use events to track user interactions in your app. Then define rules based on those interactions to control when your tips appear.")
// Place your tip near the feature you want to highlight.
TipView(tip)
Button(action: {
// Donate to the event when the user action occurs.
Task { await EventRuleTip.didTriggerControlEvent.donate() }
}, label: {
Label("Tap three times", systemImage: "lock")
})
Text("Tap the button above three times to make the tip appear.")
Spacer()
}
.padding()
.navigationTitle("Events")
}
}

In the above demonstration, we generated the corresponding events by clicking on a button. When the number of events reaches three, and the conditions of the rule are satisfied, the EventRuleTip is displayed.

Button(action: {
// Donate to the event when the user action occurs.
Task { await EventRuleTip.didTriggerControlEvent.donate() }
}, label: {
Label("Tap three times", systemImage: "lock")
})

TipKit also provides a synchronous version of the event generation method (sendDonation) that includes callback functions.

Button(action: {
// Donate to the event when the user action occurs.
EventRuleTip.didTriggerControlEvent.sendDonation{
print("donate a didTriggerControlEvent")
}
}, label: {
Label("Tap three times", systemImage: "lock")
})

We can judge events based on multiple dimensions:

// Number of events >= 3
$0.donations.count >= 3

// Number of events within a week < 3
$0.donations.donatedWithin(.week).count < 3

// Number of events within three days > 3
$0.donations.donatedWithin(.days(3)).count > 3

Currently, in each generated Event, TipKit only records the time of event creation and has not yet opened up for custom DonationInfo. If custom DonationInfo is made available, we will be able to add more additional information when creating events, enabling more targeted rule settings.

public func donate(_ donation: DonationInfo) async

We can define various events, such as entering a specific view, clicking a button, or the application receiving network data, and use TipKit events as a means of recording and filtering, and apply them to other scenarios (detailed in the next article).

Rules apply

If we do not set rules for a certain tip, we can consider it to have a default rule that is always true.

We can also create multiple rules within a Tip. In the Tip protocol, the definition of rules is @Tips.RuleBuilder var rules: [Self.Rule] { get }, which is also a Result Builder. The multiple rules are combined using the ANDrelationship, meaning that all rules must be satisfied for the Tip to be displayed. For example, we can merge the two rules mentioned earlier using the following approach.

var rules: [Rule] {
#Rule(Self.didTriggerControlEvent) {
$0.donations.count > 3
}
#Rule(Self.$isLoggedIn) {
$0 == true
}
}

Only display the Tip when isLoggedIn is true and the number of didTriggerControlEvent events exceeds three.

The ways to invalidate a Tip

In the code above, the following code appears twice::

tip.invalidate(reason: .actionPerformed)
configuration.tip.invalidate(reason: .tipClosed)

These two lines of code have the same functionality, which is to invalidate a certain Tip and record the reason.

Currently, TipKit provides three types of reasons for Tip invalidation:

  • actionPerformed: mainly used for invalid operations generated by developers in the code.
  • tipClosed: this reason is recorded when the close button (x) on the Tip view is clicked.
  • displayCountExceeded: when the number of times a Tip is displayed exceeds the set threshold, TipKit will automatically invalidate it and record this reason (explained in detail below).

Please note that invalidation Tip and hiding Tip are two different concepts.

We use rules to determine whether a Tip meets the display conditions. However, one prerequisite is that the Tip must not have already been invalidated. If a Tip has been invalidated, even if the display rules are met, TipKit will not display it.

Setting Maximum Display Count for Tip with Options

In the previous text, we mentioned another reason for invalidating a Tip: displayCountExceeded. By defining options in the Tip, we can control the maximum number of times it is displayed.

struct OptionTip: Tip {
var title: Text {
Text("Edit Actions in One Place")
}

var options: [Option] {
// Show this tip once.
Tips.MaxDisplayCount(1)
}
}

In the above code, we use the Tips.MaxDisplayCount(1) setting to ensure that the view of this Tip (whether it's TipViewor popoverTip) can only be displayed once. Once it has been displayed, TipKit will mark this Tip as invalid.

TipKit also provides another option to ignore the global display frequency strategy (see below):

Tips.IgnoresDisplayFrequency(true)

Setting Global Display Frequency Strategy for Tip via Configuration

Maybe someone would wonder, if a rule of a tip evaluates to true, will it continue to be displayed as long as it is not invalid? Wouldn’t this cause user dissatisfaction?

TipKit has already taken this into consideration, so it allows developers to set the global Tip display frequency strategy through Configuration.

struct TipKitExamplesApp: App {
init() {
try? Tips.configure([
// The system shows no more than one tip per day.
.displayFrequency(.daily)
])
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

By setting .displayFrequency(.daily) for configure, we can make the Tip that has not invalid only display once per day when the rule is true. Other settings include: hourly, weekly, monthly, immediate (no display frequency restrictions).

When the options of a certain Tip are set to Tips.IgnoresDisplayFrequency(true), it will ignore the global display frequency settings.

Reset all TipKit data

We can use the following code to reset all saved Tip data for the current application, including events, expiration status, display counts, etc. This command is typically used when testing or making significant changes to the application.

try Tips.resetDatastore()

This method should be executed before try? Tips.configure().

Override the global strategy to test tip appearance and display

In order to facilitate testing, you can use the following API to force showing or hiding the Tip:

// Show all defined tips in the app.
try? Tips.showAllTipsForTesting()

// Show some tips, but not all.
try? Tips.showTipsForTesting([EventRuleTip.self, ParameterRuleTip.self])

// Hide all tips defined in the app.
try? Tips.hideAllTipsForTesting()

Set the location for saving TipKit data.

We can also modify the location where TipKit saves data. When using App Group, multiple apps or components can share the same TipKit data source. For example, if a tip is invalidated in App A, the invalidation status will also be reflected in App B.

try? Tips.configure([
.datastoreLocation(.groupContainer(identifier: "appGroup-id"))
])

Or save the data to a specified directory.

try? Tips.configure([
.datastoreLocation(.url(URL.documentsDirectory))
])

By default, TipKit’s data is saved in the Application Support directory.

Next

In this article, we introduce the basic usage of TipKit. In the next article, we will explore more about TipKit, including the data storage mechanism of TipKit, using TipKit in UIKit, using TipKit as a statistical tool in non-tooltip domains, and advanced topics such as how to implement fully custom views (without using TipView and popoverView).

A Chinese version of this post is available here.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com