All skills
rshankras avatar

/tipkit-generator

@32566aa

Generate TipKit infrastructure with inline/popover tips, rules, display frequency, and testing utilities. Use when adding contextual tips or feature discovery to an iOS/macOS app.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/tipkit-generator

This session only. Nothing lands on disk.

templates.md

≈5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

TipKit Code Templates

Tips.configure() Setup

Basic Configuration

import TipKit

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    try? Tips.configure([
                        .displayFrequency(.daily),
                        .datastoreLocation(.applicationDefault)
                    ])
                }
        }
    }
}

Configuration with Testing Support

import TipKit

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    #if DEBUG
                    // Reset tips on every launch during development
                    try? Tips.resetDatastore()
                    #endif

                    try? Tips.configure([
                        .displayFrequency(.daily),
                        .datastoreLocation(.applicationDefault)
                    ])
                }
        }
    }
}

Display Frequency Options

// Tips can show as soon as eligible
.displayFrequency(.immediate)

// At most one tip per hour
.displayFrequency(.hourly)

// At most one tip per day (recommended for most apps)
.displayFrequency(.daily)

// At most one tip per week
.displayFrequency(.weekly)

// At most one tip per month
.displayFrequency(.monthly)

DataStore Location Options

// Default location managed by the system
.datastoreLocation(.applicationDefault)

// Custom URL (useful for isolating test data)
.datastoreLocation(.url(FileManager.default
    .urls(for: .cachesDirectory, in: .userDomainMask)
    .first!
    .appending(path: "tips-store")))

Tip Protocol Conformance

Minimal Tip (Title Only)

import TipKit

struct SearchTip: Tip {
    var title: Text {
        Text("Try searching")
    }
}

Tip with Title and Message

import TipKit

struct SearchTip: Tip {
    var title: Text {
        Text("Search your library")
    }

    var message: Text? {
        Text("Quickly find any item by name, tag, or date.")
    }
}

Tip with Title, Message, and Image

import TipKit

struct SearchTip: Tip {
    var title: Text {
        Text("Search your library")
    }

    var message: Text? {
        Text("Quickly find any item by name, tag, or date.")
    }

    var image: Image? {
        Image(systemName: "magnifyingglass")
    }
}

Tip with Actions

import TipKit

struct UpgradeTip: Tip {
    var title: Text {
        Text("Unlock more features")
    }

    var message: Text? {
        Text("Upgrade to Pro for unlimited access.")
    }

    var actions: [Action] {
        Action(id: "learn-more", title: "Learn More")
        Action(id: "dismiss", title: "Not Now")
    }
}

Handling actions in the view:

TipView(upgradeTip) { action in
    if action.id == "learn-more" {
        showUpgradeSheet = true
    }
    // Tip auto-dismisses after any action tap
}

Parameter-Based Rules

Boolean Parameter

import TipKit

struct AdvancedSearchTip: Tip {
    @Parameter
    static var hasUsedBasicSearch: Bool = false

    var title: Text {
        Text("Try advanced search")
    }

    var message: Text? {
        Text("Use filters to narrow your results.")
    }

    var rules: [Rule] {
        #Rule(Self.$hasUsedBasicSearch) { $0 == true }
    }
}

Setting the parameter from elsewhere in the app:

func onBasicSearchPerformed() {
    AdvancedSearchTip.hasUsedBasicSearch = true
}

Numeric Parameter

import TipKit

struct PowerUserTip: Tip {
    @Parameter
    static var itemsCreated: Int = 0

    var title: Text {
        Text("You are on a roll")
    }

    var message: Text? {
        Text("Try using templates to create items even faster.")
    }

    var rules: [Rule] {
        #Rule(Self.$itemsCreated) { $0 >= 5 }
    }
}

Incrementing the parameter:

func onItemCreated() {
    PowerUserTip.itemsCreated += 1
}

Event-Based Rules

Basic Event Rule

import TipKit

struct FilterTip: Tip {
    static let listViewed = Tips.Event(id: "listViewed")

    var title: Text {
        Text("Filter your results")
    }

    var message: Text? {
        Text("Tap the filter icon to narrow down what you see.")
    }

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

Donating the event:

struct ListView: View {
    var body: some View {
        List { /* ... */ }
            .onAppear {
                FilterTip.listViewed.donate()
            }
    }
}

Event with Associated Value

import TipKit

struct ShareTip: Tip {
    static let photoViewed = Tips.Event(id: "photoViewed")

    var title: Text {
        Text("Share this photo")
    }

    var message: Text? {
        Text("Tap the share button to send this to friends.")
    }

    var rules: [Rule] {
        #Rule(Self.photoViewed) {
            $0.donations.count >= 2
        }
    }
}

Combining Multiple Rules

Parameter + Event

import TipKit

struct ExportTip: Tip {
    @Parameter
    static var hasCreatedDocument: Bool = false

    static let documentEdited = Tips.Event(id: "documentEdited")

    var title: Text {
        Text("Export your work")
    }

    var message: Text? {
        Text("Tap Export to save as PDF or share with others.")
    }

    var image: Image? {
        Image(systemName: "square.and.arrow.up")
    }

    var rules: [Rule] {
        #Rule(Self.$hasCreatedDocument) { $0 == true }
        #Rule(Self.documentEdited) { $0.donations.count >= 2 }
    }
}

All rules must be satisfied (logical AND) for the tip to become eligible.


Tip Options

MaxDisplayCount

import TipKit

struct SwipeToDeleteTip: Tip {
    var title: Text {
        Text("Swipe to delete")
    }

    var message: Text? {
        Text("Swipe left on any item to delete it.")
    }

    // Tip will be shown at most 3 times across app sessions
    var options: [TipOption] {
        MaxDisplayCount(3)
    }
}

IgnoresDisplayFrequency

import TipKit

struct CriticalTip: Tip {
    var title: Text {
        Text("Important update")
    }

    var message: Text? {
        Text("Your data has been migrated. Review your settings.")
    }

    // This tip ignores the global .displayFrequency setting
    var options: [TipOption] {
        IgnoresDisplayFrequency(true)
    }
}

Combining Options

var options: [TipOption] {
    MaxDisplayCount(5)
    IgnoresDisplayFrequency(true)
}

Centralized Event Definitions

When multiple tips share events, centralize them:

// TipEvents.swift
import TipKit

enum TipEvents {
    static let appLaunched = Tips.Event(id: "appLaunched")
    static let listViewed = Tips.Event(id: "listViewed")
    static let itemCreated = Tips.Event(id: "itemCreated")
    static let searchPerformed = Tips.Event(id: "searchPerformed")
}

Reference from tip structs:

struct FilterTip: Tip {
    var title: Text {
        Text("Filter your results")
    }

    var rules: [Rule] {
        #Rule(TipEvents.listViewed) { $0.donations.count >= 3 }
    }
}

Inline Tips (TipView)

Basic Inline Tip

import SwiftUI
import TipKit

struct SearchView: View {
    let searchTip = SearchTip()

    var body: some View {
        VStack {
            TipView(searchTip)
            SearchBar()
            ResultsList()
        }
    }
}

Inline Tip with Custom Arrow Direction

TipView(searchTip, arrowEdge: .bottom)

Inline Tip with Action Handling

TipView(upgradeTip) { action in
    switch action.id {
    case "learn-more":
        showUpgradeSheet = true
    case "dismiss":
        break // Tip auto-dismisses
    default:
        break
    }
}

Popover Tips

Basic Popover

import SwiftUI
import TipKit

struct ToolbarView: View {
    let filterTip = FilterTip()

    var body: some View {
        HStack {
            Spacer()
            Button("Filter", systemImage: "line.3.horizontal.decrease.circle") {
                showFilters.toggle()
            }
            .popoverTip(filterTip)
        }
    }
}

Popover with Custom Arrow Direction

Button("Sort") {
    // action
}
.popoverTip(sortTip, arrowEdge: .top)

Tip Invalidation

Invalidate on Action Performed

struct SearchView: View {
    let searchTip = SearchTip()

    var body: some View {
        VStack {
            TipView(searchTip)
            SearchBar(onSearch: { query in
                performSearch(query)
                searchTip.invalidate(reason: .actionPerformed)
            })
        }
    }
}

Invalidation Reasons

// User performed the action the tip describes
tip.invalidate(reason: .actionPerformed)

// Tip was shown enough times (reached MaxDisplayCount)
// This happens automatically -- you do not call this manually

// User closed the tip via the X button
// This happens automatically when the user taps dismiss

Invalidate from Elsewhere

Hold a reference to the same tip instance, or define a static method:

struct SearchTip: Tip {
    var title: Text {
        Text("Try searching")
    }

    // Call this when the feature is discovered
    static func markAsDiscovered() {
        // Create a temporary instance to invalidate all displays of this tip
        SearchTip().invalidate(reason: .actionPerformed)
    }
}

TipGroup (Ordered Tips)

Basic TipGroup

import SwiftUI
import TipKit

struct OnboardingTipsView: View {
    @State var tipGroup = TipGroup(.ordered) {
        CreateItemTip()
        EditItemTip()
        ShareItemTip()
    }

    var body: some View {
        VStack {
            // Only the current tip in the group is shown
            TipView(tipGroup.currentTip!)
        }
    }
}

TipGroup with Priority Ordering

// .ordered: tips appear in the order listed, one at a time
// Each tip must be dismissed or invalidated before the next appears
@State var tipGroup = TipGroup(.ordered) {
    WelcomeTip()       // Shows first
    SearchTip()        // Shows second, after WelcomeTip is dismissed
    FilterTip()        // Shows third
}

TipGroup in a View

struct ContentView: View {
    @State var tipGroup = TipGroup(.ordered) {
        WelcomeTip()
        SearchTip()
        FilterTip()
    }

    var body: some View {
        VStack {
            if let currentTip = tipGroup.currentTip {
                TipView(currentTip)
            }

            MainContent()
        }
    }
}

Testing Utilities

TipsConfiguration Helper

// TipsConfiguration.swift
import TipKit

enum TipsConfiguration {
    /// Call once at app startup
    static func configure(frequency: Tips.ConfigurationOption.DisplayFrequency = .daily) {
        try? Tips.configure([
            .displayFrequency(frequency),
            .datastoreLocation(.applicationDefault)
        ])
    }

    /// Show all tips regardless of rules and frequency (debug only)
    static func showAllForTesting() {
        Tips.showAllTipsForTesting()
    }

    /// Show only specific tips for testing
    static func showForTesting(_ tips: [any Tip.Type]) {
        Tips.showTipsForTesting(tips)
    }

    /// Reset all tip state (debug only)
    static func resetAll() {
        try? Tips.resetDatastore()
    }
}

Debug Menu Integration

#if DEBUG
struct TipsDebugMenu: View {
    var body: some View {
        Section("Tips (Debug)") {
            Button("Reset All Tips") {
                TipsConfiguration.resetAll()
            }
            Button("Show All Tips") {
                TipsConfiguration.showAllForTesting()
            }
            Button("Show Search Tip Only") {
                TipsConfiguration.showForTesting([SearchTip.self])
            }
        }
    }
}
#endif

SwiftUI Preview Support

#Preview {
    SearchView()
        .task {
            try? Tips.resetDatastore()
            Tips.showAllTipsForTesting()
            try? Tips.configure()
        }
}

Complete Example: Feature Discovery Flow

This example shows a complete tip setup for an app with search, filter, and export features.

Tip Definitions

// SearchTip.swift
import TipKit

struct SearchTip: Tip {
    static let appOpened = Tips.Event(id: "appOpened")

    var title: Text {
        Text("Search your items")
    }

    var message: Text? {
        Text("Use the search bar to quickly find what you need.")
    }

    var image: Image? {
        Image(systemName: "magnifyingglass")
    }

    var rules: [Rule] {
        #Rule(Self.appOpened) { $0.donations.count >= 2 }
    }
}
// FilterTip.swift
import TipKit

struct FilterTip: Tip {
    @Parameter
    static var hasSearched: Bool = false

    var title: Text {
        Text("Narrow your results")
    }

    var message: Text? {
        Text("Tap the filter icon to show only what matters.")
    }

    var image: Image? {
        Image(systemName: "line.3.horizontal.decrease.circle")
    }

    var rules: [Rule] {
        #Rule(Self.$hasSearched) { $0 == true }
    }

    var options: [TipOption] {
        MaxDisplayCount(3)
    }
}
// ExportTip.swift
import TipKit

struct ExportTip: Tip {
    @Parameter
    static var hasFilteredResults: Bool = false

    static let itemViewed = Tips.Event(id: "itemViewed")

    var title: Text {
        Text("Export your results")
    }

    var message: Text? {
        Text("Share or save your filtered results as a PDF.")
    }

    var image: Image? {
        Image(systemName: "square.and.arrow.up")
    }

    var rules: [Rule] {
        #Rule(Self.$hasFilteredResults) { $0 == true }
        #Rule(Self.itemViewed) { $0.donations.count >= 3 }
    }
}

View Integration

// ContentView.swift
import SwiftUI
import TipKit

struct ContentView: View {
    let searchTip = SearchTip()
    let filterTip = FilterTip()
    let exportTip = ExportTip()

    @State private var searchText = ""
    @State private var showFilters = false

    var body: some View {
        NavigationStack {
            VStack {
                // Inline tip above search bar
                TipView(searchTip)

                SearchBar(text: $searchText, onCommit: {
                    searchTip.invalidate(reason: .actionPerformed)
                    FilterTip.hasSearched = true
                })

                ResultsList(searchText: searchText)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Filter", systemImage: "line.3.horizontal.decrease.circle") {
                        showFilters.toggle()
                        filterTip.invalidate(reason: .actionPerformed)
                        ExportTip.hasFilteredResults = true
                    }
                    .popoverTip(filterTip)
                }

                ToolbarItem(placement: .secondaryAction) {
                    Button("Export", systemImage: "square.and.arrow.up") {
                        exportResults()
                        exportTip.invalidate(reason: .actionPerformed)
                    }
                    .popoverTip(exportTip)
                }
            }
            .onAppear {
                SearchTip.appOpened.donate()
            }
        }
    }

    private func exportResults() {
        // Export logic
    }
}

App Entry Point

// MyApp.swift
import SwiftUI
import TipKit

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    #if DEBUG
                    try? Tips.resetDatastore()
                    #endif

                    try? Tips.configure([
                        .displayFrequency(.daily),
                        .datastoreLocation(.applicationDefault)
                    ])
                }
        }
    }
}

Patterns: Good vs Bad

Tip Definition

// ✅ Good: One tip per file, clear naming, meaningful image
struct SearchTip: Tip {
    var title: Text { Text("Search your library") }
    var message: Text? { Text("Find items by name, tag, or date.") }
    var image: Image? { Image(systemName: "magnifyingglass") }
}
// ❌ Bad: Multiple tips crammed into one file with vague names
struct Tips {
    struct Tip1: Tip { /* ... */ }
    struct Tip2: Tip { /* ... */ }
    struct Tip3: Tip { /* ... */ }
}

Tips.configure() Placement

// ✅ Good: Configure early in the app lifecycle
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    try? Tips.configure([
                        .displayFrequency(.daily),
                        .datastoreLocation(.applicationDefault)
                    ])
                }
        }
    }
}
// ❌ Bad: Configure inside a deeply nested view (may run too late or multiple times)
struct SomeNestedView: View {
    var body: some View {
        Text("Hello")
            .task {
                try? Tips.configure()  // Too late, may miss early tips
            }
    }
}

Display Frequency

// ✅ Good: Use .daily or .weekly for production to avoid overwhelming users
.displayFrequency(.daily)
// ❌ Bad: Using .immediate in production -- users see every tip at once
.displayFrequency(.immediate)

Tip Invalidation

// ✅ Good: Invalidate the tip instance that is currently displayed
struct SearchView: View {
    let searchTip = SearchTip()

    var body: some View {
        VStack {
            TipView(searchTip)
            Button("Search") {
                performSearch()
                searchTip.invalidate(reason: .actionPerformed)
            }
        }
    }
}
// ❌ Bad: Creating a new instance to invalidate (does not affect the displayed tip)
Button("Search") {
    performSearch()
    SearchTip().invalidate(reason: .actionPerformed)  // New instance -- no effect on displayed tip
}

Parameter Updates

// ✅ Good: Update parameter at the point where the condition becomes true
func onSearchPerformed() {
    FilterTip.hasSearched = true
}
// ❌ Bad: Setting the parameter in the tip's own init (defeats the purpose of rules)
struct FilterTip: Tip {
    @Parameter static var hasSearched: Bool = false
    init() { Self.hasSearched = true }  // Always true -- rule is pointless
}

Event Donation

// ✅ Good: Donate events at natural user interaction points
List(items) { item in
    ItemRow(item: item)
}
.onAppear {
    FilterTip.listViewed.donate()
}
// ❌ Bad: Donating events in a timer or background task (inflates counts artificially)
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
    FilterTip.listViewed.donate()  // Fires every second -- meaningless
}

TipGroup Usage

// ✅ Good: Use TipGroup when tips should appear in a logical learning sequence
@State var onboardingTips = TipGroup(.ordered) {
    WelcomeTip()
    CreateItemTip()
    ShareItemTip()
}

var body: some View {
    VStack {
        if let tip = onboardingTips.currentTip {
            TipView(tip)
        }
        MainContent()
    }
}
// ❌ Bad: Manually tracking tip ordering with state variables
@State private var currentTipIndex = 0
// Then manually showing/hiding tips based on index -- fragile and error-prone

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    This skill is a development tool designed to generate TipKit infrastructure for Apple platform applications. It automates the creation of SwiftUI/TipKit code, configuration files, and integration points through local file operations and user interaction. No malicious patterns or security risks were identified.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/tipkit-generator