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

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 a task modifier on your root view or call it in your AppDelegate.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

FrequencyBehavior
.immediateNo throttling. Tips appear as soon as rules are met.
.hourlyAt most one tip per hour across the entire app.
.dailyAt most one tip per day. Recommended for most consumer apps.
.weeklyConservative. Good for apps where tips should feel rare.
.monthlyVery 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() before Tips.configure(_:) in your Preview task to 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)

ScenarioRecommendation
Discoverable but non-obvious featuresUse TipKit. This is its primary purpose.
First-launch onboarding walkthroughUse a dedicated onboarding flow instead.
Tooltips that must appear every timeUse a custom popover. TipKit retires tips.
iOS 16 or earlier deployment targetTipKit requires iOS 17. Roll your own.
A/B testing tip contentUse feature flags to pick which Tip struct to use.
Tips depending on server-side stateUpdate parameter rules from your network layer.
Coordinating 10+ tips across the appUse TipGroup and .displayFrequency(.daily).

Summary

  • Conform to the Tip protocol and provide title, message, and optionally image and actions to define a discoverable hint.
  • Attach tips to views with .popoverTip(_:) for callout-style presentation, or embed TipView directly in your layout for inline placement.
  • Use @Parameter for state-based eligibility rules and Event with donation counts for behavior-based rules. Combine multiple rules with AND logic in the rules array.
  • 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 with maxDisplayCount.
  • 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.