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:
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.
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
andshouldDisplay
. 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.
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
}
}
}
}
}
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:
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.
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:
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
:
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
:
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:
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.
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.
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):
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.
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.
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:
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:
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:
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.
MyTip.didTriggerControlEvent.sendDonation()
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:
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.
var rules: [Rule] {
#Rule(Self.didTriggerControlEvent){
$0.donations.count > 3
}
}
The id
would be:
MyTip.event.didTriggerControlEvent.count(donationsCount) > Optional(3)
Each Rule is saved as a CoreRuleRecord entry. In validation, they are combined with an AND
relationship.
var rules: [Rule] {
#Rule(Self.didTriggerControlEvent){
$0.donations.count > 3
}
#Rule(Self.$show){
$0
}
}
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 theid
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 sameid
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.
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.