肘子的 Swift 记事本

Mastering TipKit: Advanced

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the article section, 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 the Tip view (without using TipView and popoverTip), how to use TipKit in UIKit, and how TipKit can share data between different applications. Finally, we will try to answer some common questions related to TipKit.

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

See the essence through phenomena.

The TipKit framework greatly simplifies the difficulty of adding tooltips in an application. By using ready-made tooltip views like TipView and popoverTip, developers can focus on the content of the tooltip without having to worry too much about the visual implementation.

However, these pre-made tooltip views are just auxiliary tools provided by TipKit. The essence of TipKit lies in its concept of “contract-based design”. In other words, TipKit allows you to define the content and display rules of the tooltip in code form, without needing to consider the specific implementation. These rules and content form a contract between you and TipKit. TipKit dynamically determines whether to display the tooltip based on this contract, and developers only need to focus on the changes in states or events.

Therefore, the essence of TipKit lies not in the external visual effects, but in the internal logical expression. It helps developers describe the rules for generating tooltips in a declarative manner, while the specific implementation of the tooltips can be fully customized. We can imagine TipKit as a rule engine that determines the requirements for displaying tooltips, and the visualization of these rules depends on the developers themselves.

How to observe the state of a Tip

Since we consider TipKit as a rule engine for determining when to display tips, does TipKit provide an API for developers to observe the state of a specific tip? The answer is yes.

TipKit provides two methods for Tip instances: statusUpdates and shouldDisplayUpdates, which return two AsyncStreams respectively, providing information about the state changes and whether the tip should be displayed.

statusUpdates returns three states of a tip: pending (not eligible for display), available (eligible for display), and invalidated (invalidated and the reason for invalidation).

On the other hand, shouldDisplayUpdates simplifies the above information and only uses true and false to indicate whether the tip view should be displayed.

In this case, pending corresponds to false, and available corresponds to true. After a tip is invalidated, TipKit will stop observing the changes in the tip’s parameters and events, and will no longer provide updates after sending the final state change information (invalidated).

Let’s use the following code to demonstrate the process of observing the state of a specific 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)
            }
        }
    }
}

Click the “Show Toggle” button to change the value of DemoTip.show, thus affecting the result of TipKit’s judgment based on rules. Click “Invalidate” to make the Tip invalid.

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

Perhaps you have noticed that we have not added TipView or popoverTip in the current view, which fully validates the concept of “rule engine” mentioned earlier. Whether to display the Tip view depends entirely on the developer.

TipKit also provides two properties for Tip, status and shouldDisplay. Considering that the status of Tip may change frequently and these two properties do not have a good way of observation, it is not recommended to rely solely on these two properties to determine the current status of Tip.

Display Custom Tip View Based on Status

Once developers have mastered the way of observing Tip states, they can easily display any form and style of Tip views in the application.

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 in UIKit and AppKit

Since UIKit and AppKit are not reactive frameworks, even if developers use the pre-made Tip views provided by TipKit (TipUIView, TipUIPopoverViewController, TipUICollectionViewCell, TipNSView, TipNSPopover), they still need to explicitly observe the state of the Tip and then display the Tip view based on the state.

The following code is taken 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
}

Just a reminder, in the Tip protocol, the title, message, and image properties are all specific to SwiftUI. Therefore, if you want to implement fully custom views in UIKit or AppKit, it is best to add additional information to the Tip type declaration for ease of use.

A Few Questions about TipKit

TipKit understands the user-defined “Tip” through code by allowing developers to define the content, display rules, parameters, and events that influence the rules. So how does TipKit interpret the user-defined “Tip”? Does it consider a type that conforms to the Tip protocol as a Tip, or does it consider an instance created with that type as a Tip?

From the moment I started using TipKit, I have had several questions that have been bothering me:

  • Can I use the same Tip type in multiple views within one application?
  • Can different instances of the same Tip type return different property values, such as title or rules?
  • Can I use the same Tip definition for different applications (AppGroup)? Can the state of a Tip be synchronized?
  • What defines the same Tip definition? Does it mean having exactly the same code?
  • Which Tip states does TipKit persist? What mechanism is used to synchronize the state between shared Tips?
  • Does @Parameter have any type restrictions?

For the above question, there is no clear explanation provided in either the TipKit documentation or the TipKit-related session in WWDC. Fortunately, TipKit uses a familiar data persistence mechanism, so we can find the answers we are looking for.

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

  • Parameters and events in Tip are declared as static properties.
  • Modifying parameters and triggering/querying events do not require an instance.
  • TipView and popoverTip require a Tip instance as a parameter.
  • Observing the state of Tip needs to be done through an instance.

Retrieve the answer from the persistent data of TipKit.

Considering the large amount of data and diversity of data types that TipKit needs to store, UserDefaults is clearly not a good choice. Eventually, we found TipKit’s persistent data in the Application Support directory of the app (without specifying a directory or setting an AppGroupIdentifier). TipKit saves the data in a file called tips-store.db inside a directory named .tipkit.

After opening the database file, we can see the familiar figure of the Core Data data format.

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 a total of 5 entities, namely: CoreTipRecord, CoreParameterRecord, CoreEventRecord, CoreDonationRecord, and CoreRuleRecord. Understanding the composition of these five entities is helpful for answering the above questions.

CoreTipRecord

Save information related to Tip, including display date, count, and option settings. The general definition is as follows:

Swift
class CoreTipRecord {
    // Tip Type Name , for example : MyTip
    var id: String
    // pending , available , invalidated
    var statusValue: Int
    // Reason of invalidated
    var invalidationReasonValue: Int
    // The lost 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>
    var events: Set<CoreEventRecord>
}

To facilitate reading, we will no longer use the NSManagedObject approach to define types.

The id is the type name of Tip. In other words, the id value of CoreTipRecord corresponds to MyTip in the following code:

Swift
struct MyTip: Tip {}

Regardless of how many instances of MyTip are created, they correspond to the same CoreTipRecord record. And for applications and components that share TipKit data through App Group, as long as the type name is MyTip, they correspond to the same CoreTipRecord data.

tipInfo contains some additional information related to the tip, such as:

  • Display Records: All the dates on which the tip is displayed, regardless of the application or component (App Group) it is shown in.
  • Display Count: The number of times the tip has been shown.
  • Maximum Display Count Setting (Option): The maximum number of times the tip can be shown.
  • Ignore Display Frequency Policy (Option): Whether to ignore the display frequency strategy.

Whenever Tip is displayed, the date will be recorded. Similarly, the maximum display count setting applies to all members in the App Group, and the display status is shared among different members.

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

Practical experience has shown that if different Option settings are used in different applications, the settings applied by the most recent application will overwrite the previous ones. This practice is not recommended.

CoreParameterRecord

The rough definition of CoreParameterRecord is as follows:

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

    var rules: Set<CoreRuleRecord>
}

From the naming of CoreParameterRecord, it is easy to see that this object is used to store parameter information in Tip.

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

The corresponding data for CoreParameterRecord in the above code is:

  • id: Bool.MyTip+show, property type + Tip type name + property name
  • valueType: string Bool
  • valueData: Encode data of Bool.false

From this, we can see that TipKit does not impose too many restrictions on the data types that @Parameter can support. The type only needs 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 Predicate limitations, we are still unable to use the following rules (which would cause the application to crash at runtime):

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

CoreEventRecord

Below is the approximate definition of CoreEventRecord, which is used to record information related to the Event definition.

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>

    var tip: CoreTipRecord?
}

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

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

In CoreEventRecord, the id is didTriggerControlEvent.

CoreDonationRecord

The definition of CoreDonationRecord is as follows:

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

    var event: CoreEventRecord
}

Used to record the date of Donation, a new data entry will be recorded every time it is triggered.

Swift
MyTip.didTriggerControlEvent.sendDonation()

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

Since TipKit has not yet made DonationInfo public, we are unable to include custom information when triggering events. If the ability to create custom EventInfo is made available in the future, it would allow for more flexible rules to be created.

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?
}

Among them, id is the most interesting attribute, which is the string representation of the custom version of Predicate in the Rule.

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

id is:

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

Each rule is saved as a CoreRuleRecord entry. During validation, they are connected 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

Clarifying Doubts

After analyzing the persistence of TipKit data and considering my other tests, we can basically draw the following conclusions:

  • TipKit’s data is managed and saved through Core Data.
  • TipKit’s data can be shared and synchronized between different applications (AppGroup) or different devices (iCloud).
  • TipKit identifies tips based on the type name. In different applications (AppGroup), the same type name will use the same tip data source.
  • In different applications (AppGroup), for the same tip type, if code reuse is not implemented, all persistent properties (including static properties) should remain consistent, including: Parameter, Event, rules, options.
  • Properties related to appearance can be modified and adjusted as needed when creating an instance, such as: title, message, image, action.
  • The invalid status, display status, display count, and maximum display limit allowed for the same tip are shared.
  • The event trigger data for the same tip is also shared.

Regardless of whether it is mentioned in the WWDC presentation or in the tipInfo information, it is stated that TipKit supports synchronization through iCloud. However, I have not found the correct way to enable it. If someone has successfully implemented it, please let me know.

Finally

In this article, we analyzed TipKit from the perspective of a “rules engine”. Although the analysis showed that the development team has reserved some room for upgrades, the main purpose of TipKit’s design is to facilitate the display of Tip information in applications. Therefore, it does not excessively increase unnecessary capabilities in terms of data filtering efficiency and rule flexibility. Even so, TipKit still provides us with a good example of implementing a miniature “rules engine” for sharing data.

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