TipKit: Feature Discovery with Contextual Hints and Eligibility Rules
You shipped a feature three months ago. It is behind a long-press gesture. Analytics show 4% of users have discovered
it. The PM asks for a tooltip. You reach for a custom popover, a UserDefaults flag, some display-count logic, and
suddenly you have 80 lines of boilerplate that does not coordinate with other tooltips in the app. TipKit, introduced in
iOS 17, is Apple’s answer to this exact problem — a framework-level system for contextual hints with built-in
eligibility rules, frequency limits, and cross-tip coordination.
This post covers the Tip protocol, TipView and popover tips in SwiftUI, TipGroup for sequencing, eligibility rules
with the #Rule macro, display frequency configuration, and testing tips in Xcode Previews. We will not cover UIKit
integration patterns or custom tip styling beyond what the API provides — those deserve their own posts. This assumes
familiarity with SwiftUI state management and
protocols.
Note: TipKit requires iOS 17+ / macOS 14+. All code in this post uses Swift 6 strict concurrency.
Contents
- The Problem
- Creating Your First Tip
- Popover Tips and Inline Tips
- Eligibility Rules
- TipGroup: Sequencing Multiple Tips
- Display Frequency and Global Configuration
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a Pixar film catalog app where users can add movies to a favorites collection. The “Add to Favorites” action is a heart icon in the toolbar — obvious to power users, invisible to everyone else. The typical DIY approach looks something like this:
// The pre-TipKit approach — manual tooltip management
struct FilmDetailView: View {
let film: PixarFilm
@AppStorage("hasSeenFavoritesTip") private var hasSeenTip = false
@AppStorage("favoritesTipShowCount") private var showCount = 0
@State private var showingTooltip = false
var body: some View {
VStack {
FilmPosterView(film: film)
}
.toolbar {
Button("Favorite", systemImage: "heart") {
toggleFavorite()
}
.popover(isPresented: $showingTooltip) {
Text("Tap the heart to save films to your favorites!")
.padding()
}
}
.onAppear {
if !hasSeenTip, showCount < 3 {
showingTooltip = true
showCount += 1
}
}
}
}
This works for one tip. Now add five more tips across different screens and the problems compound: tips compete for
attention, there is no centralized frequency limit, dismissing one tip does not affect the others, and testing any of
this in Previews requires resetting UserDefaults manually. Every team ends up building a tip manager singleton with
display queues and priority logic — or, more commonly, they just show all tips at once and annoy users.
TipKit provides all of this out of the box: a declarative tip definition, automatic persistence, cross-tip coordination, frequency limits, parameter-based eligibility rules, and first-class testing support.
Creating Your First Tip
Apple Docs:
Tip— TipKit
A tip is a struct conforming to the Tip protocol. At minimum, it provides a title and optionally a message and
image:
import TipKit
struct FavoriteFilmTip: Tip {
var title: Text {
Text("Save to Favorites")
}
var message: Text? {
Text("Tap the heart icon to add this Pixar film to your personal collection.")
}
var image: Image? {
Image(systemName: "heart.circle")
}
}
The title, message, and image properties return SwiftUI views, so you can apply modifiers directly — bold text,
custom tints, SF Symbol rendering modes. The framework handles layout, arrow positioning, and dismiss gestures.
Configuring TipKit at Launch
Before any tip can appear, you must configure TipKit in your app’s entry point. This initializes the persistence store and sets global display parameters:
import SwiftUI
import TipKit
@main
struct PixarCatalogApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
try? Tips.configure([
.displayFrequency(.daily)
])
}
}
}
}
Tips.configure(_:) is async and should be called once at app launch. The displayFrequency parameter controls how
often any tip can appear — .immediate for no throttling, .hourly, .daily, .weekly, or .monthly. We will
cover this in detail later.
Warning: Calling
Tips.configure(_:)more than once in the same app lifecycle throws a runtime error. Gate it behind ataskmodifier on your root view or call it in yourAppDelegate.application(_:didFinishLaunchingWithOptions:).
Popover Tips and Inline Tips
TipKit provides two presentation styles: popover tips that point to a specific UI element, and inline tips that appear as embedded views in your layout.
Popover Tips
Use the .popoverTip(_:) modifier to attach a tip to a view. The tip appears as a callout bubble pointing to the anchor
view:
struct FilmDetailView: View {
let film: PixarFilm
private let favoriteTip = FavoriteFilmTip()
var body: some View {
ScrollView {
FilmPosterView(film: film)
FilmSynopsisView(film: film)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Favorite", systemImage: "heart") {
toggleFavorite()
favoriteTip.invalidate(reason: .actionPerformed)
}
.popoverTip(favoriteTip)
}
}
}
}
When the user taps the heart, we call invalidate(reason:) to permanently dismiss the tip. The .actionPerformed
reason tells TipKit the user completed the action the tip was guiding them toward — this feeds into TipKit’s analytics
and ensures the tip never reappears.
Inline Tips
Use TipView to embed a tip directly in your layout. This is useful for tips that should appear within a list or form
rather than as a floating popover:
struct FilmListView: View {
let films: [PixarFilm]
private let filterTip = FilterDiscoveryTip()
var body: some View {
List {
TipView(filterTip) // Appears as a row in the list
.listRowBackground(Color.clear)
ForEach(films) { film in
FilmRow(film: film)
}
}
.searchable(text: .constant(""))
}
}
TipView renders the tip’s title, message, and image in a standard card layout with a close button. When the user
dismisses it, TipKit records the dismissal and the view disappears automatically.
Eligibility Rules
Apple Docs:
Tips.Rule— TipKit
Static tips that appear on first launch are rarely useful. TipKit’s rule system lets you define conditions that must be met before a tip becomes eligible. There are two types of rules: parameter-based rules and event-based rules.
Parameter-Based Rules
Parameter rules check the current value of a persistent parameter. Define a parameter with @Parameter and reference it
in the rules property:
struct AdvancedSearchTip: Tip {
@Parameter
static var hasUsedBasicSearch: Bool = false
var title: Text {
Text("Try Advanced Filters")
}
var message: Text? {
Text("Long-press the search icon to filter Pixar films by decade, director, or score.")
}
var rules: [Rule] {
#Rule(Self.$hasUsedBasicSearch) { $0 }
}
}
The #Rule macro creates a rule that evaluates whenever the parameter changes. In this example, the tip only becomes
eligible after the user has performed at least one basic search. You update the parameter from anywhere in your code:
// In your search handling code
func performSearch(query: String) {
AdvancedSearchTip.hasUsedBasicSearch = true
// ... execute search
}
Event-Based Rules
Event rules track how many times something has happened. Define an event with a static Event property and donate to it
when the action occurs:
struct ShareFilmTip: Tip {
static let filmViewedEvent = Event(id: "filmViewed")
var title: Text {
Text("Share with Friends")
}
var message: Text? {
Text("Enjoyed this Pixar classic? Tap share to send it to fellow fans.")
}
var rules: [Rule] {
#Rule(Self.filmViewedEvent) { event in
event.donations.count >= 3
}
}
}
The tip becomes eligible only after the user has viewed at least 3 films. Donate to the event each time the action happens:
struct FilmDetailView: View {
let film: PixarFilm
var body: some View {
ScrollView {
FilmPosterView(film: film)
}
.task {
await ShareFilmTip.filmViewedEvent.donate()
}
}
}
Combining Multiple Rules
A tip’s rules array uses AND logic — all rules must be satisfied for the tip to appear:
struct RateFilmTip: Tip {
static let filmViewedEvent = Event(id: "filmViewedForRating")
@Parameter
static var hasCompletedOnboarding: Bool = false
var title: Text {
Text("Rate This Film")
}
var message: Text? {
Text("Tap the stars to rate this Pixar masterpiece and get better recommendations.")
}
var rules: [Rule] {
// User must have completed onboarding AND viewed at least 5 films
#Rule(Self.$hasCompletedOnboarding) { $0 }
#Rule(Self.filmViewedEvent) { event in
event.donations.count >= 5
}
}
}
Tip: Event donations are persisted across app launches. If you need to reset them during development, use
Tips.resetDatastore()— but never ship that call in production.
TipGroup: Sequencing Multiple Tips
Apple Docs:
TipGroup— TipKit
When multiple tips are eligible simultaneously, they can visually compete. TipGroup ensures only one tip from a group
is shown at a time, advancing to the next tip only after the current one is dismissed or invalidated:
struct FilmBrowserView: View {
let films: [PixarFilm]
private let tipGroup = TipGroup(.ordered) {
FilterDiscoveryTip()
SortOptionsTip()
CollectionsTip()
}
var body: some View {
NavigationStack {
List {
if let currentTip = tipGroup.currentTip {
TipView(currentTip)
.listRowBackground(Color.clear)
}
ForEach(films) { film in
FilmRow(film: film)
}
}
.navigationTitle("Pixar Films")
}
}
}
The .ordered priority ensures tips appear in the sequence you defined. After the user dismisses FilterDiscoveryTip,
the group automatically advances to SortOptionsTip. If you use the default priority, TipKit picks the most
contextually relevant tip based on eligibility and recency.
Display Frequency and Global Configuration
The Tips.configure(_:) call accepts an array of configuration options that control global behavior:
try? Tips.configure([
// How often any tip can appear
.displayFrequency(.daily),
// Persistent store location (default is fine for most apps)
.datastoreLocation(.applicationDefault)
])
Display Frequency Options
| Frequency | Behavior |
|---|---|
.immediate | No throttling. Tips appear as soon as rules are met. |
.hourly | At most one tip per hour across the entire app. |
.daily | At most one tip per day. Recommended for most consumer apps. |
.weekly | Conservative. Good for apps where tips should feel rare. |
.monthly | Very conservative. Minimal onboarding needs only. |
The frequency applies globally across all tips. If a user dismisses a tip at 9:00 AM and the frequency is .daily, no
other tip will appear until 9:00 AM the next day — regardless of how many tips are eligible.
Per-Tip Max Display Count
You can also limit how many times a specific tip appears by implementing maxDisplayCount:
struct FavoriteFilmTip: Tip {
var title: Text {
Text("Save to Favorites")
}
var maxDisplayCount: Int {
3 // Show at most 3 times before permanently retiring
}
}
Advanced Usage
Invalidating Tips Programmatically
Tips can be invalidated for two reasons:
// User performed the action the tip was guiding them to
favoriteTip.invalidate(reason: .actionPerformed)
// The tip's context is no longer relevant (e.g., feature removed)
favoriteTip.invalidate(reason: .tipClosed)
Both reasons permanently prevent the tip from reappearing. The distinction is semantic — .actionPerformed indicates
success, .tipClosed indicates the user explicitly dismissed it.
Observing Tip Status
The shouldDisplayUpdates property on a Tip is an AsyncStream you can observe to react to tip state changes:
struct FilmDetailView: View {
let film: PixarFilm
private let favoriteTip = FavoriteFilmTip()
@State private var tipIsVisible = false
var body: some View {
ScrollView {
FilmPosterView(film: film)
.overlay(alignment: .topTrailing) {
if tipIsVisible {
Circle()
.stroke(Color.accentColor, lineWidth: 2)
.frame(width: 44, height: 44)
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Favorite", systemImage: "heart") {
toggleFavorite()
favoriteTip.invalidate(reason: .actionPerformed)
}
.popoverTip(favoriteTip)
}
}
.task {
for await shouldDisplay in favoriteTip.shouldDisplayUpdates {
tipIsVisible = shouldDisplay
}
}
}
}
Testing Tips in Xcode Previews
TipKit provides testing hooks that let you force tips into specific states without waiting for rules to be satisfied. This is invaluable for iterating on tip design in Previews:
#Preview {
FilmDetailView(film: .toyStory)
.task {
try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate)
])
}
}
For more granular control, you can show or hide specific tips:
#Preview("Tip Visible") {
FilmDetailView(film: .toyStory)
.task {
try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate)
])
AdvancedSearchTip.hasUsedBasicSearch = true
}
}
#Preview("Tip Hidden") {
FilmDetailView(film: .toyStory)
.task {
try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate)
])
}
}
Tip: Call
Tips.resetDatastore()beforeTips.configure(_:)in your Previewtaskto clear all persisted tip state. This gives you a clean slate on every Preview refresh.
Tips with Action Buttons
Tips can include action buttons that let users take action directly from the tip callout:
struct ExploreCollectionsTip: Tip {
var title: Text {
Text("Discover Collections")
}
var message: Text? {
Text("Browse curated lists like 'Pixar Villains' and 'Best Sequels'.")
}
var actions: [Action] {
Action(id: "explore", title: "Browse Collections")
Action(id: "dismiss", title: "Not Now")
}
}
Handle the action in your TipView:
TipView(exploreTip) { action in
switch action.id {
case "explore":
navigateToCollections()
exploreTip.invalidate(reason: .actionPerformed)
default:
exploreTip.invalidate(reason: .tipClosed)
}
}
Performance Considerations
Persistence overhead. TipKit uses a SwiftData-backed store to persist tip state, event donations, and parameter values. The store is lightweight — typically under 100KB even with dozens of tips and thousands of event donations. Reads and writes are batched and happen off the main thread.
Rule evaluation. Rules are evaluated lazily. Parameter-based rules fire only when the parameter value changes. Event-based rules re-evaluate when a new donation is recorded. There is no polling. In practice, rule evaluation is sub-millisecond and has no measurable impact on frame rates.
Memory. Each Tip struct is stateless — the actual state lives in TipKit’s central data store. Creating tip
instances in body is cheap and does not cause redundant storage allocation.
Cold launch. Tips.configure(_:) reads from disk on first call. On a device with about 20 tips, this adds
approximately 5-15ms to launch. Call it in a .task modifier on your root view rather than synchronously in init() to
keep it off the main thread’s critical path.
Apple Docs:
Tips— TipKit
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Discoverable but non-obvious features | Use TipKit. This is its primary purpose. |
| First-launch onboarding walkthrough | Use a dedicated onboarding flow instead. |
| Tooltips that must appear every time | Use a custom popover. TipKit retires tips. |
| iOS 16 or earlier deployment target | TipKit requires iOS 17. Roll your own. |
| A/B testing tip content | Use feature flags to pick which Tip struct to use. |
| Tips depending on server-side state | Update parameter rules from your network layer. |
| Coordinating 10+ tips across the app | Use TipGroup and .displayFrequency(.daily). |
Summary
- Conform to the
Tipprotocol and providetitle,message, and optionallyimageandactionsto define a discoverable hint. - Attach tips to views with
.popoverTip(_:)for callout-style presentation, or embedTipViewdirectly in your layout for inline placement. - Use
@Parameterfor state-based eligibility rules andEventwith donation counts for behavior-based rules. Combine multiple rules with AND logic in therulesarray. TipGroup(.ordered)sequences multiple tips so only one shows at a time, preventing visual competition.- Configure global display frequency with
Tips.configure([.displayFrequency(.daily)])and per-tip limits withmaxDisplayCount. - Call
Tips.resetDatastore()in Previews and unit tests to get a clean slate. Use.displayFrequency(.immediate)during development.
TipKit pairs naturally with app lifecycle events — showing tips after a user completes onboarding or returns from background. See App Lifecycle for patterns on reacting to scene phase changes that can trigger tip parameter updates.