肘子的 Swift 记事本

Mastering TipKit: Basics

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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.

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.

Swift
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")
    }
}

https://cdn.fatbobman.com/image-20231015144407862.png

Let the tip achieve the desired effect.

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

https://cdn.fatbobman.com/image-20231015120758303.png

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

https://cdn.fatbobman.com/image-20231015120911856.png

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:

Swift
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.

Swift
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")
    }
}

https://cdn.fatbobman.com/image-20231015150453850.png

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.

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

https://cdn.fatbobman.com/image-20231015153758052.png

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

https://cdn.fatbobman.com/tipkit-popoverTip-demo-7341202.png

Swift
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)
                }
            ....
        }
    }
}

https://cdn.fatbobman.com/image-20231015154038009.png

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:

Swift
.popoverTip(tip,arrowEdge: .leading)

https://cdn.fatbobman.com/image-20231015154758785.png

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:

Swift
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)
    }
}

https://cdn.fatbobman.com/image-20231015164840908.png

This method is effective for both TipView and popoverTip views.

Using the modifiers unique to TipView.

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

https://cdn.fatbobman.com/image-20231015165115831.png

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 TipView through styles.

Swift
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())

https://cdn.fatbobman.com/image-20231015180721474.png

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.

Swift
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")
    }
}

https://cdn.fatbobman.com/tipkit-tip-with-action-demo_2023-10-15_18.17.09.2023-10-15%2018_17_49.gif

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

Swift
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.

Swift
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.

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

In this example, the implementation of the Action’s operation is done within the view because it requires the openURL provided 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.

Swift
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)

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.

Swift
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:

Swift
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.

Swift
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.

Swift
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.

Swift
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")
    }
}

https://cdn.fatbobman.com/tipkit-parameters-rule-demo_2023-10-15_19.16.25.2023-10-15%2019_17_01.gif

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:

Swift
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.

Swift
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 didTriggerControlEvent event has been triggered at least three times.

Swift
#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.

Swift
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")
    }
}

https://cdn.fatbobman.com/tipkit-event-rule-demo_2023-10-15_20.04.07.2023-10-15%2020_05_19.gif

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.

Swift
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.

Swift
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:

Swift
// 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.

Swift
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 AND relationship, 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.

Swift
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::

Swift
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.

Swift
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 TipView or 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):

Swift
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.

Swift
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.

Swift
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:

Swift
// 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.

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

Or save the data to a specified directory.

Swift
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).

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways