Mastering TipKit: Advanced

Published on Updated on

Get weekly handpicked updates on Swift and SwiftUI!

In the previous article, we introduced the basic usage of TipKit. In this article, we will discuss some advanced topics related to TipKit, such as how to fully customize Tip views (not using TipView and popoverTip), how to use TipKit with UIKit, and how TipKit can share data across different applications, as well as how to reuse Tip declaration code. Additionally, we will attempt to address some common questions and concerns about TipKit.

If you are not yet familiar with the TipKit framework, please read Mastering TipKit: Basics first.

September 2024 Update: At WWDC 2024, the functionalities of the TipKit framework were significantly expanded. This series of articles has been revised to reflect the latest updates.

Seeing Through the Surface

The TipKit framework greatly simplifies the difficulty of adding prompts within applications. By using ready-made prompt views like TipView and popoverTip, developers can focus on the content of the prompts without worrying too much about the implementation of visual effects.

However, these prefabricated prompt views are merely tools provided by TipKit. The real essence of TipKit lies in its adoption of a “contractual design” philosophy. In other words, TipKit allows you to define the content and display rules of prompts in code form without worrying about the specifics of the implementation. These rules and content form a contract between you and TipKit. TipKit dynamically decides whether to display prompts based on this contract, with developers only needing to focus on changes in state or events.

Thus, the essence of TipKit lies not in its external visual effects but in its internal logical expression. It helps developers describe the rules for generating prompts in a declarative manner, and the specific implementation of the prompts can be fully customized. We can think of TipKit as a rules engine that determines the need to display prompts, and how to visualize these rules depends entirely on the developer.

How to Observe Tip Status

Since we regard TipKit as a rules engine for determining the need to display prompts, does TipKit provide an API for developers to observe the status of a Tip? The answer is yes.

TipKit provides two methods for instances of Tip: statusUpdates and shouldDisplayUpdates, which return two AsyncStreams providing information about the status changes and display eligibility of that Tip type.

statusUpdates returns three states of a Tip: pending (does not meet display conditions), available (meets display conditions), and invalidated (invalidated and the reason for invalidation).

shouldDisplayUpdates simplifies the above information, indicating whether the Tip view can be displayed through true and false.

Where pending corresponds to false, and available corresponds to true. Once a Tip is set as invalidated, TipKit will stop observing changes in the Tip’s parameters and events and cease providing status updates after sending the last status change message (invalidated).

Let’s use the following code to demonstrate how to observe the status of a Tip:

Swift
struct DemoTip: Tip {
    var title: Text = .init("Hello World")

    @Parameter
    static var show: Bool = false

    var rules: [Rule] {
        #Rule(Self.$show) {
            $0
        }
    }
}

struct TipStatusView: View {
    let tip = DemoTip()
    var body: some View {
        List {
            Button("Show Toggle") {
                DemoTip.show.toggle()
            }
            Button("Invalidate") {
                tip.invalidate(reason: .actionPerformed)
            }
        }
        .task {
            for await status in tip.statusUpdates {
                print("Status:", status)
            }
        }
        .task {
            for await shouldDisplay in tip.shouldDisplayUpdates {
                print("Display:", shouldDisplay)
            }
        }
    }
}

Clicking the “Show Toggle” button will change the value of DemoTip.show, affecting the result of the decision made by TipKit according to the rules. Clicking “Invalidate” will invalidate the Tip.

https://cdn.fatbobman.com/tipkit-status-stream-demo_2023-10-18_15.57.18.2023-10-18%2015_59_07.gif

You may have noticed that in the current view we did not add a TipView or popoverTip, which fully validates the “rules engine” concept mentioned above. Whether to display the Tip view is entirely up to the developer.

TipKit also provides two properties for Tip, status and shouldDisplay. Considering that the status of a Tip can change frequently, and these two properties do not provide a good way to observe, it is not recommended to rely entirely on these two properties to judge the current status of a Tip.

Displaying Custom Tip Views Based on Status

Once developers understand how to observe the status of a Tip, they can easily display any form and style of prompt views in their applications based on that status.

Swift
struct TipStatusView: View {
    let tip = DemoTip()
    @State var shouldDisplay = DemoTip.show
    var body: some View {
        List {
            if shouldDisplay {
                tip.title
            }
            Button("Show Toggle") {
                DemoTip.show.toggle()
            }
            Button("Invalidate") {
                tip.invalidate(reason: .actionPerformed)
            }
        }
        .task {
            for await shouldDisplay in tip.shouldDisplayUpdates {
                withAnimation(.smooth) {
                    self.shouldDisplay = shouldDisplay
                }
            }
        }
    }
}

https://cdn.fatbobman.com/tipkit-show-tip-by-status-demo_2023-10-18_16.55.40.2023-10-18%2016_56_22.gif

Using TipKit with UIKit and AppKit

Since UIKit and AppKit are not reactive frameworks, even when using TipKit’s prefabricated Tip views (TipUIView, TipUIPopoverViewController, TipUICollectionViewCell, TipNSView, TipNSPopover), developers still need to explicitly track the status of Tips and then display the Tip views based on that status.

The following code is excerpted from Apple’s official documentation:

Swift
import TipKit
import UIKit

struct CatTracksFeatureTip: Tip {
    var title: Text { Text("Sample tip title")}
    var message: Text? { Text("Sample tip message")}
    var image: Image? { Image(systemName: "globe")}
}

class CatTracksViewController: UIViewController {
    private var catTracksFeatureTip = CatTracksFeatureTip()
    private var tipObservationTask: Task<Void, Never>?
    private weak var tipView: TipUIView?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        tipObservationTask = tipObservationTask ?? Task { @MainActor in
            for await shouldDisplay in catTracksFeatureTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let tipHostingView = TipUIView(catTracksFeatureTip)
                    tipHostingView.translatesAutoresizingMaskIntoConstraints = false

                    view.addSubview(tipHostingView)

                    view.addConstraints([
                        tipHostingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
                        tipHostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.0),
                        tipHostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.0)
                    ])

                    tipView = tipHostingView
                }
                else {
                    tipView?.removeFromSuperview()
                    tipView = nil
                }
            }
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        tipObservationTask?.cancel()
        tipObservationTask = nil
    }
}

A reminder: because in the Tip protocol, properties like title, message, image are types specific to SwiftUI, if you want to implement fully customized views in UIKit or AppKit, it’s advisable to add other additional information to the Tip type declaration to facilitate usage.

Several Questions About TipKit

TipKit allows developers to define the content, display rules, and the parameters and events that influence these rules for Tips through code. How does TipKit understand what constitutes a “Tip”? Is a type that conforms to the Tip protocol considered a Tip, or is an instance created from that type considered a Tip?

Since my initial contact with TipKit, several questions have been troubling me:

  • In an application, can the same Tip type be used in multiple views?
  • Can different instances of the same Tip type return different attribute values (such as title, rules)?
  • Between different applications (AppGroup), can the same Tip definition be used? Can the state of a Tip be synchronized?
  • What constitutes the same definition of a Tip? Does it refer to the exact same code?
  • Which states of a Tip does TipKit persist? What is the mechanism for synchronizing states between shared Tips?
  • Are there any type restrictions on @Parameter?

Neither TipKit’s documentation nor the WWDC Session on TipKit provide clear explanations for these questions. Fortunately, TipKit uses a familiar data persistence mechanism, from which we can find the answers we are looking for.

Before looking further for answers, we first need to understand the following:

  • Parameters and events in a Tip are declared as static properties.
  • Modifications to parameters and triggers and queries for events do not require an instance.
  • TipView and popoverTip require an instance of Tip as a parameter.
  • Observing the status of a Tip requires an instance.

These insights guide us into a deeper understanding of how TipKit conceptualizes and handles Tips within an application and potentially across multiple applications or components. Understanding these fundamental aspects will aid in better utilizing TipKit’s capabilities in various development scenarios.

Finding Answers from TipKit’s Persistent Data

Given the volume and variety of data that TipKit needs to store, UserDefaults is clearly not a suitable choice. Eventually, we found TipKit’s persistent data in the Application Support directory of the app (when no specific directory is specified and no AppGroupIdentifier is set). TipKit saves its data in a file named tips-store.db located in a directory called .tipkit.

After opening the database file, we can see traces of the familiar Core Data data format (Apple mentioned at WWDC 2024 that TipKit uses SwiftData for persistence).

Please read the article How Core Data Saves Data in SQLite to understand the persistent data format of Core Data.

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

TipKit has created 5 entities (Entity), namely: CoreTipRecord, CoreParameterRecord, CoreEventRecord, CoreDonationRecord, and CoreRuleRecord. Understanding these five entities helps in answering the above questions.

CoreTipRecord

Saves information related to a Tip, including display dates, counts, Option settings, etc. Here’s a rough definition:

Swift
class CoreTipRecord {
    // The identifier of a Tip; if id is not customized, the default id will be the name of the Tip type.
    var id: String
    // pending, available, invalidated
    var statusValue: Int
    // Reason of invalidation
    var invalidationReasonValue: Int
    // The last display date
    var lastDisplayed: Date?
    // Unclear
    var content: ConstellationContent?
    // Some info of Tip, including: display count, options setting, etc
    var tipInfo: [String: Any]

    var rules: Set<CoreRuleRecord>
}

For readability, we will not use NSManagedObject to define types.

id is the unique identifier for a Tip. For example, in the code below, the id value of MyTip is my id 1:

Swift
struct MyTip: Tip {
  var id: String {
    "my id 1"
  }
}

If id is not customized, the default value is the Tip’s type name. For example, the id corresponding to the following code in persistence would be MyTip:

Swift
struct MyTip: Tip {}

The id property is very important as the identifier of a Tip, meaning that the same Tip declaration can create different storage records by adjusting its id value, which is the basis for implementing the reuse of Tip declarations.

tipInfo saves some other information related to the prompt, such as:

  • Display record: All display dates, regardless of which app (App Group) the Tip is displayed in.
  • Number of displays
  • Maximum display count setting (Option)
  • Whether to ignore display frequency policy (Option)

Every time a Tip is displayed, the display date is recorded. Similarly, the maximum display count setting applies to all members of an App Group, and the display status is shared among different members.

Since the Tip’s Option is also persisted, the same Option settings should be used in different applications (App Group).

Practice has shown that using different Option settings in different applications leads to the later one overwriting the earlier settings, which is not recommended.

CoreParameterRecord

Here’s a rough definition of CoreParameterRecord:

Swift
class CoreParameterRecord {
    // Composite name of a parameter property
    var id: String
    // The name of the parameter property type
    var valueType: String
    // Encoded default value
    var valueData: Data?

    var rules: Set<CoreRuleRecord>
}

From the name CoreParameterRecord, it’s easy to see that this object is used to save information about the parameters (Parameter) in a Tip.

Swift
struct MyTip: Tip {
  @Parameter
  static var show:Bool = false
}

In the code above, the corresponding CoreParameterRecord data would be:

  • id: Bool.MyTip+show, property type + Tip type name + property name
  • valueType: the string Bool
  • valueData: Encoded data for Bool.false

From this, we can see that TipKit does not place many restrictions on the data types that @Parameter can support; types only need to conform to the Encodable protocol.

Swift
struct MyData: Codable {
  var id: String
  var count: Int
}

struct MyTip: Tip {
  @Parameter
  static var data: MyData = MyData(id:"1", count: 1)
}

Unfortunately, due to current limitations with Predicate, we still cannot use the following rule (this rule would cause the app to crash at runtime):

Swift
var rules: [Rule] {
    #Rule(Self.$data){
        $0.count > 3
    }
}

CoreEventRecord

Here is a rough definition of CoreEventRecord, which is used to record information related to event definitions.

Swift
class CoreEventRecord {
    // Event property name
    var id: String
    // No data recorded yet
    var eventInfo: [String: Any]

    var donations: Set<CoreDonationRecord>
    var rules: Set<CoreRuleRecord>
}

When the same event is triggered across multiple applications (AppGroup) or even multiple devices (iCloud sync), all trigger data is shared.

Swift
static let didTriggerControlEvent = Event(id: "didTriggerControlEvent")

In CoreEventRecord, the id is didTriggerControlEvent.

In the persistent data for events, there is no direct link created with Tip. This means that TipKit relies on the id value of the event to differentiate between events. To avoid event name duplication (especially in cases of Tip declaration reuse), it is advisable to group related event definitions together:

Swift
enum MyTipEvents {
  static let didTriggerControlEvent = Tips.Event(id: "didTriggerControlEvent")
  static let didVisitCount = Tips.Event(id: "didVisitCount")
}

In the rules of a Tip, use a unified event declaration:

Swift
struct Tip1: Tip {
  ...
  var rules: [Rule] {
    #Rule(MyTipEvents.didTriggerControlEvent){
      $0.donations.count > 3
    }
  }
}

struct Tip2: Tip {
  ...
  var rules: [Rule] {
    #Rule(MyTipEvents.didVisitCount){
      $0.donations.count > 5
    }
  }
}

CoreDonationRecord

The definition of CoreDonationRecord is as follows:

Swift
class CoreDonationRecord {
    var date: Date
    var donationInfo: DonationInfo?

    var event: CoreEventRecord
}

This is used to record the date of each Donation, with each trigger creating a new record.

Swift
MyTip.didTriggerControlEvent.sendDonation()

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

Since TipKit has not yet made DonationInfo public, we cannot attach custom information when triggering events. If the ability to customize EventInfo is opened in the future, it will be possible to create more flexible rules.

CoreRuleRecord

The definition of CoreRuleRecord is as follows, used to record the Rule settings of a Tip:

Swift
class CoreRuleRecord {
    var id: String
    var categoryValue: Int
    var statusValue: Int
    var predicate: Predicate
    var ruleInfo: [String: Any]

    var event: CoreEventRecord?
    var parameter: CoreParameterRecord?
    var parent: CoreRuleRecord?

    var subrules: Set<CoreRuleRecord>
    var tip: CoreTipRecord?
}

Here, the id is the most interesting property as it represents a custom string version of the Predicate in the Rule.

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

The id would be:

Swift
MyTip.event.didTriggerControlEvent.count(donationsCount) > Optional(3)

Each Rule is saved as a CoreRuleRecord entry. In validation, they are combined with an AND relationship.

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

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

Clarifications

Through analyzing TipKit’s persistent data and a series of tests, we have drawn the following key conclusions:

  • TipKit manages and saves data using SwiftData.
  • With SwiftData, TipKit data can be shared and synchronized across different applications (AppGroup) or devices (via iCloud).
  • TipKit identifies different Tips through the id of the Tip. Even using the same Tip declaration, as long as the id is different, they are considered different Tip instances.
  • Appearance-related attributes of a Tip, such as title, message, image, and actions, can be modified and adjusted at the time of instance creation as needed.
  • Information such as the invalidated state, display state, number of clicks, and allowed maximum display amount of the same Tip are shared.
  • There is no direct link between events and Tips; events are identified by their id, and the trigger data for the same id is shared.

Tip Reuse Mechanism

In the previous section, we discussed how TipKit relies on the id of a Tip to recognize different Tip instances. By overriding the default identifier of a Tip, you can reuse the same tip structure based on its content, thus enhancing code reusability and efficiency.

In the following example, we define an ItemTip, but by assigning different ids when constructing Tip instances, we generate multiple specific Tip instances using the same piece of code.

Swift
struct Item: Identifiable {
  let title: String
  var id: String {
    title
  }
}

struct ItemTip: Tip {
  let item: Item

  var title: Text {
    Text(item.title)
  }

  var id: String {
    item.id
  }
}

let items: [Item] = [
  .init(title: "hello"),
  .init(title: "fatbobman"),
  .init(title: "world"),
]

struct ReuseDemo: View {
  @State var tips = TipGroup(.ordered) {
    ItemTip(item: items[0])
    ItemTip(item: items[1])
    ItemTip(item: items[2])
  }

  var body: some View {
    List {
      TipView(tips.currentTip)
      ForEach(items) { item in
        Text(item.title)
      }
    }
  }
}

When we need to provide tips for dynamically added data, different ids can be constructed based on the new data. This reuse mechanism for Tips greatly optimizes code organization and maintenance, reducing the need for repeated declarations.

Conclusion

In this article, we analyzed TipKit from the perspective of a “rule engine”. Although the analysis showed that the development team left some room for upgrades, the primary purpose of TipKit’s design is to facilitate the display of Tips in applications. Therefore, it does not unnecessarily increase capabilities in terms of data filtering efficiency and rule-setting flexibility. Even so, TipKit still provides us with a good example of a micro “rule engine” that implements shareable data.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed