# TipKit Patterns

Longer worked patterns for the `tipkit` skill. Every example targets iOS 17 or
later and follows Swift 6.3 conventions; a heading says so when a newer
release is needed.

## Contents

- [A tip with both a parameter and an event rule](#a-tip-with-both-a-parameter-and-an-event-rule)
- [Placing TipView and popoverTip](#placing-tipview-and-popovertip)
- [Counting a manual action to suggest a shortcut](#counting-a-manual-action-to-suggest-a-shortcut)
- [Events that carry a value](#events-that-carry-a-value)
- [Custom TipViewStyle](#custom-tipviewstyle)
- [Sequencing with TipGroup (iOS 18)](#sequencing-with-tipgroup-ios-18)
- [Testing](#testing)
- [Actions that open a feature](#actions-that-open-a-feature)
- [Working alongside an onboarding flow](#working-alongside-an-onboarding-flow)
- [A full app](#a-full-app)

## A tip with both a parameter and an event rule

This tip advertises bulk export. It waits until the person is signed in and
has exported single recipes a few times, so the advanced feature is offered
only to someone who already knows the basic one.

```swift
import TipKit

struct BulkExportTip: Tip {
    @Parameter
    static var isSignedIn: Bool = false

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

    var title: Text { Text("Export a whole cookbook") }

    var message: Text? {
        Text("Select several recipes and share them as one file.")
    }

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

    var options: [TipOption] {
        Tips.MaxDisplayCount(5)
    }

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

### Where to donate

Donate at the exact place the action completes, for example in the export
callback. Every donation raises the count the rule checks.

```swift
func exportRecipe(_ recipe: Recipe) async throws {
    try await exporter.write(recipe)
    await BulkExportTip.recipeExported.donate()
}
```

### Where to set parameters

Set a parameter when the underlying state changes, such as in the sign-in
success path. The value is stored in the TipKit datastore, so it survives
relaunches.

```swift
func didFinishSignIn() {
    BulkExportTip.isSignedIn = true
}
```

## Placing TipView and popoverTip

### A tip as a list row, the same tip as a toolbar popover

An inline `TipView` can be a row of a `List`; it animates away once dismissed
or invalidated. The toolbar button below also carries the tip as a popover and
invalidates it when tapped.

```swift
struct PantryListView: View {
    let filterTip = PantryFilterTip()
    @State private var isFilterSheetShown = false

    var body: some View {
        List {
            Section {
                TipView(filterTip)
            }
            ForEach(pantryItems) { item in
                PantryRow(item: item)
            }
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("Filter", systemImage: "slider.horizontal.3") {
                    filterTip.invalidate(reason: .actionPerformed)
                    isFilterSheetShown = true
                }
                .popoverTip(filterTip, arrowEdge: .top)
            }
        }
    }
}
```

### Pointing at a navigation bar button

A popover on a bar button aims its arrow at exactly that control.

```swift
Button("Revert", systemImage: "arrow.uturn.backward") {
    revertLastChange()
}
.popoverTip(revertTip, arrowEdge: .top)
```

### Introducing a new tab (iOS 18)

TipKit has no `popoverTip` for `Tab`: the modifier is declared on `View`, and a
`Tab` is `TabContent`, so `.popoverTip` on it does not compile. Put the tip
inside the new tab's content instead.

```swift
TabView {
    Tab("Recipes", systemImage: "book") {
        RecipesView()
    }
    Tab("Meal Plan", systemImage: "calendar") {
        VStack {
            TipView(mealPlanTip)
            MealPlanView()
        }
    }
}
```

## Counting a manual action to suggest a shortcut

A progressive-disclosure pattern: count how often someone does something the
slow way, and after a threshold (five here) suggest the faster path.

```swift
struct SaveShortcutTip: Tip {
    var title: Text { Text("Press Command-S to save") }
    var message: Text? { Text("Works whenever a keyboard is attached.") }

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

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

struct NoteEditorView: View {
    @State private var noteText = ""
    let keyboardHint = SaveShortcutTip()

    var body: some View {
        VStack(spacing: 0) {
            TipView(keyboardHint)
            TextEditor(text: $noteText)
        }
        .toolbar {
            Button("Save") {
                saveDraft()
                Task { await SaveShortcutTip.manualSave.donate() }
            }
        }
    }
}
```

## Events that carry a value

An event can record a value with every donation. Declare it as
`Tips.Event<Value>` where the value type is `Codable` and `Sendable`, filter
on the value in the rule, and pass the value to `donate`. Inside `#Rule`, give
`filter` its closure in parentheses: the macro rejects trailing-closure
syntax with "The filter function is not supported in this rule".

```swift
struct PlaybackInfo: Codable, Sendable {
    let tier: String
}

struct OfflineDownloadTip: Tip {
    static let episodePlayed = Tips.Event<PlaybackInfo>(id: "episodePlayed")

    var title: Text { Text("Download for offline listening") }

    var rules: [Rule] {
        #Rule(Self.episodePlayed) {
            $0.donations.filter({ $0.tier == "premium" }).count >= 3
        }
    }
}

Task {
    await OfflineDownloadTip.episodePlayed.donate(PlaybackInfo(tier: "premium"))
}
```

## Custom TipViewStyle

`Configuration` gives the style `title`, `message`, `image` and `actions`.

### A branded card

```swift
struct BrandCardTipStyle: TipViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(alignment: .top, spacing: 12) {
                configuration.image
                    .font(.title3)
                    .frame(width: 44, height: 44, alignment: .center)
                    .background(
                        LinearGradient(
                            colors: [.orange, .pink],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        ),
                        in: RoundedRectangle(cornerRadius: 12)
                    )
                    .foregroundStyle(.white)
                VStack(alignment: .leading, spacing: 4) {
                    configuration.title
                        .font(.title3.weight(.semibold))
                    configuration.message
                        .font(.callout)
                        .foregroundStyle(.gray)
                }
            }
            if configuration.actions.isEmpty == false {
                HStack(spacing: 8) {
                    ForEach(configuration.actions) { tipAction in
                        Button(action: tipAction.handler) {
                            tipAction.label()
                        }
                        .buttonStyle(.bordered)
                    }
                }
            }
        }
        .padding()
        .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18))
    }
}
```

Apply it to one `TipView`, or set it on a parent such as a `NavigationStack`
so it becomes the default for everything inside:

```swift
NavigationStack {
    PantryListView()
}
.tipViewStyle(BrandCardTipStyle())
```

### A compact variant

For narrow spaces like toolbars and sidebars, a single line on a tinted
capsule is enough.

```swift
struct SlimTipStyle: TipViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        Label {
            configuration.title
                .font(.caption.bold())
                .lineLimit(1)
        } icon: {
            configuration.image
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(Color.accentColor.opacity(0.15), in: Capsule())
    }
}
```

## Sequencing with TipGroup (iOS 18)

A group shows only its current tip. Once that tip is dismissed or acted on,
the next eligible tip takes its place.

```swift
struct ArticleFeedScreen: View {
    @State private var onboarding = TipGroup(.ordered) {
        FollowTopicTip()
        SaveArticleTip()
        ReaderModeTip()
    }

    var body: some View {
        ScrollView {
            if let tip = onboarding.currentTip {
                TipView(tip) { _ in
                    tip.invalidate(reason: .actionPerformed)
                }
            }
            ArticleFeed()
        }
    }
}
```

### Popovers that move between controls

Give each control a popover for its own tip type by casting
`currentTip` to that type. As the group advances, the popover jumps to the next control.

```swift
HStack {
    Button("Follow", systemImage: "plus") { follow() }
        .popoverTip(onboarding.currentTip as? FollowTopicTip)
    Button("Save", systemImage: "bookmark") { save() }
        .popoverTip(onboarding.currentTip as? SaveArticleTip)
}
```

## Testing

### Previews

Reset the store and force tips on so they render in Xcode previews:

```swift
#Preview {
    PantryListView()
        .task { @MainActor in
            try? Tips.resetDatastore()
            Tips.showAllTipsForTesting()
        }
}
```

To look at one tip in isolation, show only that type:

```swift
#Preview("Filter tip only") {
    PantryListView()
        .task { @MainActor in
            try? Tips.resetDatastore()
            Tips.showTipsForTesting([PantryFilterTip.self])
        }
}
```

### Unit tests

Reset and configure in `setUp`, and reset again in `tearDown` so tests do not
leak state into each other. Move a tip toward eligibility by setting its
parameter and donating its event.

```swift
import XCTest
import TipKit
@testable import RecipeBox

final class BulkExportTipTests: XCTestCase {
    private func clearTipStore() throws {
        try Tips.resetDatastore()
    }

    override func setUp() async throws {
        try clearTipStore()
        try Tips.configure()
    }

    override func tearDown() async throws {
        try clearTipStore()
    }

    func testSignedInUserAfterThreeExports_BecomesEligible() async {
        BulkExportTip.isSignedIn = true
        for _ in 1...3 {
            await BulkExportTip.recipeExported.donate()
        }
        let tip = BulkExportTip()
        // Assert on the tip's status here once eligibility is observable.
        _ = tip
    }

    func testInvalidatedTip_IsNoLongerEligible() async {
        let tip = BulkExportTip()
        tip.invalidate(reason: .actionPerformed)
        // Assert the tip reports an invalidated status here.
    }
}
```

The status assertions are left as placeholders; how you read a tip's status
depends on the TipKit API available to the test target.

### UI tests

Force tips on from the test by adding a launch argument before `launch()`.
`XCUIApplication` is main-actor isolated, so mark the test `@MainActor`:

```swift
@MainActor
func testFilterTipAppears() {
    let app = XCUIApplication()
    app.launchArguments += ["--show-all-tips"]
    app.launch()
}
```

Tests that have nothing to do with tips pass `--hide-all-tips` instead so no
tip covers the controls under test. The app reads both flags in `init`,
before configuring:

```swift
enum TipTestFlag: String {
    case showAll = "--show-all-tips"
    case hideAll = "--hide-all-tips"
}

func applyTipTestFlags() {
    let arguments = ProcessInfo.processInfo.arguments
    if arguments.contains(TipTestFlag.showAll.rawValue) {
        Tips.showAllTipsForTesting()
    } else if arguments.contains(TipTestFlag.hideAll.rawValue) {
        Tips.hideAllTipsForTesting()
    }
}

init() {
    #if DEBUG
    applyTipTestFlags()
    #endif
    try? Tips.configure()
}
```

## Actions that open a feature

A primary action can take the person straight into the feature: it sets the
presentation state and invalidates the tip as performed. A "Not Now" style
secondary action closes the tip with `.tipClosed`.

```swift
struct MealPlanTip: Tip {
    var title: Text { Text("Plan your week") }

    var actions: [Action] {
        Action(id: "start-plan", title: "Start a Plan")
        Action(id: "not-now", title: "Not Now")
    }
}

struct PlannerEntryView: View {
    let planTip = MealPlanTip()
    @State private var isPlannerShown = false

    var body: some View {
        TipView(planTip) { action in
            if action.id == "start-plan" {
                isPlannerShown = true
                planTip.invalidate(reason: .actionPerformed)
            } else {
                planTip.invalidate(reason: .tipClosed)
            }
        }
        .sheet(isPresented: $isPlannerShown) {
            MealPlannerView()
        }
    }
}
```

## Working alongside an onboarding flow

Give both tips a `didFinishOnboarding` parameter (the has-completed-onboarding
flag). A welcome tip uses
`== false`, so it only appears for people who skipped onboarding. A discovery
tip uses `== true`, so it only appears once onboarding is done.

```swift
struct WelcomeTip: Tip {
    var title: Text { Text("Your recipes, all in one place") }

    @Parameter
    static var didFinishOnboarding: Bool = false

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

struct CollectionsTip: Tip {
    var title: Text { Text("Group recipes into collections") }

    @Parameter
    static var didFinishOnboarding: Bool = false

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

When onboarding finishes, flip both parameters, invalidate the welcome tip
explicitly, and close the sheet. The host view below does this in the
onboarding completion closure.

The host view remembers completion in `@AppStorage`, presents onboarding in a
sheet on first appearance, and shows both tips:

```swift
struct RecipeBoxHomeView: View {
    @AppStorage("onboardingDone") private var onboardingDone = false
    @State private var isOnboardingShown = false
    let welcomeTip = WelcomeTip()
    let collectionsTip = CollectionsTip()

    var body: some View {
        VStack {
            TipView(welcomeTip)
            RecipeGrid()
                .popoverTip(collectionsTip)
        }
        .onAppear {
            if onboardingDone == false {
                isOnboardingShown = true
            }
        }
        .sheet(isPresented: $isOnboardingShown) {
            OnboardingView {
                onboardingDone = true
                WelcomeTip.didFinishOnboarding = true
                CollectionsTip.didFinishOnboarding = true
                welcomeTip.invalidate(reason: .actionPerformed)
                isOnboardingShown = false
            }
        }
    }
}
```

## A full app

Three tips: one with no rules, one that waits for three "item added"
donations, and one that waits until a collection exists.

```swift
import SwiftUI
import TipKit

struct ScanBarcodeTip: Tip {
    var title: Text { Text("Scan to add") }
    var image: Image? { Image(systemName: "barcode.viewfinder") }
}

struct BatchEditTip: Tip {
    var title: Text { Text("Edit several items at once") }

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

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

struct ShareCollectionTip: Tip {
    var title: Text { Text("Send a collection to a friend") }

    @Parameter
    static var hasCollection: Bool = false

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

@main
struct PantryApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                PantryHomeView()
            }
        }
    }

    init() {
        #if DEBUG
        applyTipTestFlags()
        #endif
        try? Tips.configure([
            .datastoreLocation(.applicationDefault),
            .displayFrequency(.daily)
        ])
    }
}

struct PantryHomeView: View {
    let scanTip = ScanBarcodeTip()
    let batchTip = BatchEditTip()
    let shareTip = ShareCollectionTip()

    var body: some View {
        List {
            TipView(scanTip)
            PantryItemsSection()
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("Edit", systemImage: "checklist") {
                    batchTip.invalidate(reason: .actionPerformed)
                    beginBatchEdit()
                }
                .popoverTip(batchTip)
            }
            ToolbarItem(placement: .secondaryAction) {
                Button("Send", systemImage: "person.2") {
                    shareTip.invalidate(reason: .actionPerformed)
                    shareCollection()
                }
                .popoverTip(shareTip)
            }
            ToolbarItem(placement: .bottomBar) {
                Button("Add Item") {
                    addItem()
                    Task { await BatchEditTip.itemAdded.donate() }
                }
            }
        }
    }
}
```

The "Add Item" button carries no tip of its own; it only donates the event
that eventually makes `BatchEditTip` eligible. `applyTipTestFlags()` is the
DEBUG helper from the UI tests section.
