---
name: tipkit
description: "TipKit in-app tips (iOS 17+): Tip protocol, TipView, popoverTip, rules, events, TipGroup, styles, CloudKit sync. Use when building or reviewing feature-discovery tooltips, onboarding flows, first-run experiences, coach marks, contextual tips or other feature-education UI."
metadata:
  source: multi-agent-pipeline
---

# TipKit

TipKit (iOS 17 and later) shows short hints that teach a feature at the
moment it matters. The framework owns the hard parts: it decides when a tip
is eligible, throttles how often tips appear, and remembers which tips a
person has already learned so they stop appearing.

Tips are for discovery and progressive disclosure. They can be dismissed and
do not come back, so anything critical or safety related goes in an alert or
an inline warning instead.

## Setup

Configure TipKit once, in the `App` initializer, before the first view is
built. `Tips.configure` opens the tips datastore and starts evaluating rules;
if it runs later (in `onAppear` or `.task`), tip views can render against a
store that is not ready yet.

```swift
import SwiftUI
import TipKit

@main
struct GardenJournalApp: App {
    init() {
        try? Tips.configure([
            .datastoreLocation(.applicationDefault)
        ])
    }

    var body: some Scene {
        WindowGroup {
            JournalHomeView()
        }
    }
}
```

### Where the datastore lives

| Option | When to pick it |
| --- | --- |
| `.applicationDefault` | The app sandbox default. Correct for most apps. |
| `.groupContainer(identifier:)` | Tip state shared between the app and its extensions. |
| `.url(_:)` | A file URL you choose, when you need full control. |

### Syncing tip state with CloudKit

When the app runs on several devices, sync tip state so a person who learned
a feature on their iPhone is not taught it again on their iPad. Add a CloudKit
container next to the datastore option (`cloudKitContainer` needs iOS 18):

```swift
try? Tips.configure([
    .datastoreLocation(.applicationDefault),
    .cloudKitContainer(.named("iCloud.com.example.gardenjournal"))
])
```

## Defining a tip

A tip is a struct that conforms to `Tip`. Only `title` (a `Text`) is
required. `message` (`Text?`) adds a line of context and `image` (`Image?`)
adds a leading icon. The callout is small, so write the title as a short
instruction.

```swift
struct PinPlantTip: Tip {
    var title: Text {
        Text("Pin a plant")
    }

    var message: Text? {
        Text("Pinned plants stay at the top of your journal.")
    }

    var image: Image? {
        Image(systemName: "pin")
    }
}
```

Optional members:

- `actions`: buttons shown inside the tip.
- `rules`: conditions that must hold before the tip can appear.
- `options`: per-tip display settings such as a maximum count.

A tip moves through three states. It is pending while its rules are not yet
satisfied, eligible once every rule passes, and invalidated when it is
dismissed, acted on, or invalidated in code. An invalidated tip stays hidden
for good unless the datastore is reset.

## Showing a tip

### Inline with TipView

`TipView(tip)` places the tip in the layout as a rounded card that animates
in and out. Use it for tips that sit among scrolling content.

```swift
struct JournalHomeView: View {
    let pinTip = PinPlantTip()

    var body: some View {
        VStack {
            TipView(pinTip)
            PlantListView()
        }
    }
}
```

### As a popover

`.popoverTip(tip)` attaches the tip to a view and draws an arrow to it. Use it
when the tip explains one particular control. Pass `arrowEdge:` to choose the
arrow direction, or leave it out and let the system decide.

```swift
Button("Water", systemImage: "drop") {
    logWatering()
}
.popoverTip(waterTip, arrowEdge: .bottom)
```

### A custom look with TipViewStyle

To restyle tips across the app, adopt `TipViewStyle` and implement
`makeBody(configuration:)`. The configuration carries `title` plus optional
`image` and `message`.

```swift
struct LeafTipStyle: TipViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack(alignment: .top, spacing: 10) {
            configuration.image
                .foregroundStyle(.green)
            VStack(alignment: .leading, spacing: 4) {
                configuration.title
                    .font(.headline)
                configuration.message
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
    }
}
```

Apply it with `.tipViewStyle(LeafTipStyle())` on a single tip view or on a
container to cover everything below it.

## Rules

Rules decide eligibility. Every rule in `rules` has to pass before the tip is
shown. There are two kinds.

### Parameter rules (app state)

Declare a `@Parameter` static on the tip, write a rule against its projected
value, and assign the static when the state changes.

```swift
struct SortPlantsTip: Tip {
    @Parameter
    static var hasOpenedList: Bool = false

    var title: Text { Text("Sort by watering date") }

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

SortPlantsTip.hasOpenedList = true
```

### Event rules (user actions)

Declare a `Tips.Event`, donate to it every time the action happens, and write
a rule on the donations. A rule can look at how many donations there are or
when they happened.

```swift
struct QuickLogTip: Tip {
    static let entryAdded = Tips.Event(id: "entryAdded")

    var title: Text { Text("Log faster with a long press") }

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

Task { await QuickLogTip.entryAdded.donate() }
```

Event rules fit tips that should appear only after someone has repeated a
task several times without finding the shortcut that would help.

### Several rules together

List more than one `#Rule` and all of them must pass (logical AND):

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

### How often tips appear

Per tip, `options` adjusts display behavior:

```swift
var options: [TipOption] {
    Tips.MaxDisplayCount(3)
    Tips.IgnoresDisplayFrequency(true)
}
```

- `MaxDisplayCount(3)` stops the tip after three showings in total.
- `IgnoresDisplayFrequency(true)` lets this tip bypass the global limit.

The global limit is set at configuration time with
`.displayFrequency(...)`, taking `.immediate`, `.hourly`, `.daily`, `.weekly`
or `.monthly`. With `.daily`, the whole app shows at most one tip per day,
except for tips that opt out with `IgnoresDisplayFrequency(true)`.

```swift
try? Tips.configure([.displayFrequency(.daily)])
```

## Actions

`actions` adds buttons to a tip. Each `Action(id:title:)` gets an identifier
and a label. Handle taps in the closure passed to `TipView`, switching on the
identifier.

```swift
struct ReminderTip: Tip {
    var title: Text { Text("Never miss a watering") }

    var actions: [Action] {
        Action(id: "set-reminder", title: "Set Reminder")
        Action(id: "show-guide", title: "How It Works")
    }
}

TipView(reminderTip) { action in
    switch action.id {
    case "set-reminder":
        reminderTip.invalidate(reason: .actionPerformed)
        openReminderEditor()
    case "show-guide":
        isGuidePresented = true
    default:
        break
    }
}
```

## Tip groups

When one screen has several tips, put them in a `TipGroup` (iOS 18 and
later) so only one is visible at a time. The `.ordered` priority shows them in
the order listed. Render `currentTip` when it is not nil; invalidating it
makes the next eligible tip current.

```swift
struct PlantDetailView: View {
    @State private var tips = TipGroup(.ordered) {
        PinPlantTip()
        SortPlantsTip()
        QuickLogTip()
    }

    var body: some View {
        VStack {
            if let tip = tips.currentTip {
                TipView(tip)
            }
            Button("Next hint") {
                tips.currentTip?.invalidate(reason: .actionPerformed)
            }
        }
    }
}
```

## Controlling tips from code

### Invalidation

Call `tip.invalidate(reason:)` once the person performs the action the tip
describes, or when the tip no longer applies.

| Reason | Meaning |
| --- | --- |
| `.actionPerformed` | The person did what the tip suggests. |
| `.displayCountExceeded` | The tip hit its maximum display count. |
| `.tipClosed` | The person closed the tip. |

### Testing helpers

| Call | Effect |
| --- | --- |
| `Tips.showAllTipsForTesting()` | Shows every tip and ignores rules. |
| `Tips.showTipsForTesting([PinPlantTip.self, QuickLogTip.self])` | Shows only the listed tip types. |
| `Tips.hideAllTipsForTesting()` | Hides all tips, for UI tests that are not about tips. |
| `try? Tips.resetDatastore()` | Clears tip state, invalidations and event donations. |

None of these may run in production. Put them behind `#if DEBUG` or a
launch-argument check.

### Launch arguments for development schemes

```swift
init() {
    #if DEBUG
    if ProcessInfo.processInfo.arguments.contains("--show-all-tips") {
        Tips.showAllTipsForTesting()
    }
    #endif
    try? Tips.configure()
}
```

Add `--show-all-tips` under Arguments Passed On Launch in a development
scheme.

## Pitfalls

- Configuring in `onAppear` or `.task`. The race leaves tips missing or
  flickering. Configure in `App.init`.
- Several `TipView`s on screen at once. People feel swamped and start
  ignoring tips. Sequence them with `TipGroup(.ordered)` and `currentTip`.
- Leaving a tip up after the person already did the thing. A stale hint costs
  trust. Call `invalidate(reason: .actionPerformed)` in the action of the
  control that carries the `.popoverTip`.
- Shipping `showAllTipsForTesting()` without a guard. It skips rules and
  frequency limits, so every user sees every tip at once. Wrap it in
  `#if DEBUG`.
- Long titles. They truncate or wrap awkwardly. Keep the action in the title
  and the explanation in the message.
- Using a tip for critical or safety information. Tips can be dismissed and
  never return. Use an alert or an inline warning.

## Review checklist

- [ ] `Tips.configure()` runs in `App.init`, before any view renders.
- [ ] Titles are short, clear and action oriented, roughly 40 characters or
      fewer.
- [ ] Each tip is invalidated when its action is performed.
- [ ] Rules keep tips from all firing on first launch.
- [ ] Screens with more than one tip use `TipGroup`.
- [ ] `showAllTipsForTesting` and `resetDatastore` sit behind `#if DEBUG`.
- [ ] CloudKit sync is configured when the app runs on more than one device.
- [ ] Display frequency suits the app; `.daily` or `.weekly` fits most.
- [ ] Tips are used for feature discovery only, never for critical information.
- [ ] A custom `TipViewStyle` is applied consistently where the default look
      clashes with the design.
- [ ] Tip actions are handled and the tip is invalidated in the handler.
- [ ] Event donations happen at the right user-action points.
- [ ] Tip types are `Sendable`, and TipKit is configured on the `@MainActor`.

## References

- [references/tipkit-patterns.md](references/tipkit-patterns.md): complete
  tips with rules and events, placement, donation values, custom styles, tip
  group sequencing, previews and tests, onboarding and a full app example.
