
Tipkit
- 2.6k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
tipkit is a Swift skill for Apple TipKit in-app tips, popover tips, rules, TipGroup, and CloudKit tip state sync.
About
TipKit guides feature-discovery UI with inline tips, popover tips, rule-gated education, and lightweight coach marks on iOS 17 plus across iPhone, iPad, Mac, TV, watch, and visionOS. Tips.configure must run once during app initialization before any tip displays, never from onAppear or task modifiers. Tip definitions use Tip protocol with title, message, image, rules, events, and actions; presentation via TipView or popoverTip modifiers. Rules and Events gate display frequency with displayFrequency options like daily and invalidation via tips.invalidate. iOS 18 adds TipGroup with firstAvailable or ordered sequences, CloudKit sync via cloudKitContainer for cross-device tip state, and MaxDisplayDuration cumulative caps. iOS 26 adds resetEligibility to restore invalidated tips without wiping the datastore. Testing overrides in DEBUG builds force tip display. Common mistakes include configuring in views, inconsistent app-group option settings, and overusing ordered TipGroups. Review checklist verifies configure timing, rule logic, and CloudKit entitlements when syncing tip state across devices.
- Tips.configure once at app launch before any tip can display.
- Tip, TipView, popoverTip, rules, events, and invalidation patterns.
- iOS 18 TipGroup ordered sequences and CloudKit tip sync.
- Display frequency, MaxDisplayDuration, and testing overrides.
- Common mistakes for onAppear configure and app-group consistency.
Tipkit by the numbers
- 2,613 all-time installs (skills.sh)
- +107 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #76 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
tipkit capabilities & compatibility
- Capabilities
- tipkit configure and datastore location setup · tip definition with rules, events, and actions · tipview and popovertip swiftui presentation · tipgroup sequencing and cloudkit cross device sy · testing overrides and invalidation management
- Use cases
- frontend · ui design
- Platforms
- macOS
- Runs
- Runs locally
- Pricing
- Free
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill tipkitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.6k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I add contextual in-app tips with rules and testing overrides without misconfiguring TipKit?
Implement and audit Apple TipKit in-app tips, popover tips, rules, events, and iOS 18 TipGroup coach marks.
Who is it for?
iOS developers adding feature discovery coach marks and contextual help with TipKit on iOS 17 plus.
Skip if: Skip for generic SwiftUI navigation architecture or long first-run onboarding flows outside TipKit.
When should I use this skill?
User adds Tip, TipView, popoverTip, TipGroup, tip rules, or audits TipKit display frequency.
What you get
Configured TipKit datastore, rule-gated tips, and popover or inline presentation with correct availability gates.
- TipKit tip definitions
- TipGroup onboarding flow
- Custom TipViewStyle implementation
By the numbers
- All examples target iOS 17+ with Swift 6.3 conventions
- Reference covers eight TipKit pattern areas including testing and previews
Files
TipKit
Use TipKit for small, contextual feature-discovery moments: inline tips, popover tips, rule-gated education, and lightweight coach marks. Keep generic SwiftUI architecture, navigation, layout, and long first-run onboarding flows in their sibling skills unless TipKit presentation is the core issue.
Contents
- Availability
- Configure TipKit
- Design Good Tips
- Define Tips
- Present Tips
- Rules and Events
- Options and Invalidation
- Actions and Styles
- Tip Groups
- Testing
- Common Mistakes
- Review Checklist
- References
Availability
TipKit's core Tip, TipView, popoverTip, rules, events, options, and testing overrides are available on iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+, watchOS 10+, and visionOS 1+.
Gate newer APIs explicitly:
| API | Availability | Use |
|---|---|---|
TipGroup | iOS 18+ | Defaults to .firstAvailable; use .ordered only for sequences where later tips wait for earlier invalidation. |
.cloudKitContainer(...) | iOS 18+ | Sync tip state, parameters, events, and display counts across devices. |
MaxDisplayDuration | iOS 18+ | Automatically invalidate after cumulative display time. |
resetEligibility() | iOS 26+ | Make a previously invalidated tip eligible again without resetting the datastore. |
Configure TipKit
Call Tips.configure(_:) once during app initialization, before any tip can display. Do not configure TipKit from a view's onAppear or .task.
import SwiftUI
import TipKit
@main
struct MyApp: App {
init() {
do {
try Tips.configure([
.datastoreLocation(.applicationDefault),
.displayFrequency(.daily)
])
} catch {
assertionFailure("TipKit configuration failed: \(error)")
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Use .datastoreLocation(.groupContainer(identifier:)) only when an app and extension or app-group members intentionally share tip state. Keep option settings consistent across app-group members because TipKit persists option state with the tip record.
CloudKit Sync
Use CloudKit sync only on iOS 18+ and later. Enable iCloud + CloudKit and Background Modes > Remote notifications, then pass a container:
try Tips.configure([
.cloudKitContainer(.named("iCloud.com.example.app.tips"))
])Prefer a dedicated container with a .tips suffix. .automatic uses the first entitled .tips container when present, then falls back to the primary container.
Design Good Tips
Tips are small, transient help. Use them for features people can understand and try in a few simple steps. If the flow needs a long explanation, multiple screens, or critical safety/error information, use a tutorial, alert, inline warning, or onboarding flow instead.
Follow HIG-aligned defaults:
- Keep titles short, direct, and action-oriented.
- Use one or two sentences; avoid promotional or unrelated copy.
- Place tips near the feature they explain.
- Prefer inline tips when hiding nearby UI would interrupt the task.
- Prefer popover tips when preserving the current layout matters and the tip can
point to a specific control.
- Use rules and display frequency so only the right audience sees each tip.
- Avoid repeating an icon in the tip when the popover already points to that icon.
Define Tips
Tip conforms to Identifiable and Sendable. Provide title at minimum; add message, image, actions, rules, options, and id only when they improve the feature-discovery moment.
import TipKit
struct FavoriteTip: Tip {
var title: Text { Text("Save to Favorites") }
var message: Text? { Text("Tap the heart to keep items for quick access.") }
var image: Image? { Image(systemName: "heart.fill") }
}By default, TipKit uses the tip type name as id. Override id for reusable tips whose persisted state should vary by content:
struct NewItemTip: Tip {
let itemID: Item.ID
var id: String { "NewItemTip-\(itemID)" }
var title: Text { Text("New Item Available") }
}Use stable, concrete identifiers. Do not derive IDs from transient copy or unstable ordering.
Present Tips
Use TipView for inline tips:
let favoriteTip = FavoriteTip()
VStack {
TipView(favoriteTip, arrowEdge: .bottom)
ItemListView()
}Use .popoverTip when the tip should point to a control:
Button {
toggleFavorite()
favoriteTip.invalidate(reason: .actionPerformed)
} label: {
Image(systemName: "heart")
}
.popoverTip(favoriteTip, arrowEdge: .top)Rules and Events
Rules are ANDed together. A tip becomes eligible only when every rule passes.
Use @Parameter for persisted app state:
struct FavoriteTip: Tip {
@Parameter static var hasSeenList = false
var title: Text { Text("Save to Favorites") }
var rules: [Rule] {
#Rule(Self.$hasSeenList) { $0 == true }
}
}Use Tips.Event for repeated user actions. TipKit queries the most recent 1000 donations by default, so keep event rules bounded and intentional.
struct ShortcutTip: Tip {
static let manualSaveEvent = Tips.Event(id: "manualSave")
var title: Text { Text("Save Faster") }
var rules: [Rule] {
#Rule(Self.manualSaveEvent) {
$0.donations.donatedWithin(.week).count >= 3
}
}
}
ShortcutTip.manualSaveEvent.sendDonation()For richer event rules, define Tips.Event<DonationInfo> where DonationInfo: Codable, Sendable. Keep donation payloads small.
Group related event definitions in a shared namespace when several tips use the same events; event IDs are the persistence boundary, so collisions can create confusing eligibility.
Options and Invalidation
Use options sparingly; frequency and invalidation rules are part of the tip's persisted behavior.
struct DailyTip: Tip {
var title: Text { Text("Try Filters") }
var options: [any TipOption] {
MaxDisplayCount(3)
IgnoresDisplayFrequency(false)
}
}MaxDisplayDuration is iOS 18+. It counts cumulative display time and has a minimum continuous display duration before automatic invalidation can occur. Do not use it as a replacement for explicit invalidate(reason:) when the app knows the taught action or ordered step is complete.
Call invalidate(reason:) when the user performs the discovered action or the tip is no longer relevant. Invalidation is permanent until the datastore is reset or, on iOS 26+, the specific tip calls await resetEligibility().
favoriteTip.invalidate(reason: .actionPerformed)Use .tipClosed for explicit dismissal and .displayCountExceeded or .displayDurationExceeded only when describing automatic invalidation outcomes.
Actions and Styles
Add Action buttons when the user needs a direct route to settings, more information, or a setup flow.
struct FeatureTip: Tip {
var title: Text { Text("Try the New Editor") }
var actions: [Action] {
Action(id: "open-editor", title: "Open Editor")
Action(id: "learn-more", title: "Learn More")
}
}
TipView(FeatureTip()) { action in
switch action.id {
case "open-editor":
openEditor()
case "learn-more":
showHelp()
default:
break
}
}For custom appearance, prefer TipViewStyle.Configuration values over reading directly from a concrete tip instance. That preserves labels, handlers, and modifiers applied to the TipView.
struct CompactTipStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .top) {
configuration.image?
VStack(alignment: .leading) {
configuration.title?
configuration.message?
ForEach(configuration.actions) { action in
Button(action: action.handler) {
action.label()
}
}
}
}
.padding()
}
}Tip Groups
TipGroup is iOS 18+. Store groups in SwiftUI state so the observable group object persists across view updates. In every review of a TipGroup(.ordered) plan, explicitly distinguish the default priority from ordered sequences: TipGroup defaults to .firstAvailable, and TipGroup(.ordered) is required when each later tip must wait for all previous tips to be invalidated.
struct OnboardingView: View {
@State private var tips = TipGroup(.ordered) {
WelcomeTip()
SearchTip()
FilterTip()
}
var body: some View {
VStack {
TipView(tips.currentTip)
ContentView()
}
}
}TipGroup defaults to .firstAvailable, which shows the first eligible tip in the group. Use .ordered only for true sequences, and invalidate each taught step when the user completes it so the next ordered tip can advance. MaxDisplayDuration can cap display time, but it is not the sequencing mechanism for an ordered group. Cast currentTip when the same group spans multiple controls:
Button("Search") { openSearch() }
.popoverTip(tips.currentTip as? SearchTip)Testing
Use testing overrides only in debug/test code, and apply them before Tips.configure(_:).
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--reset-tips") {
try? Tips.resetDatastore()
}
if ProcessInfo.processInfo.arguments.contains("--show-all-tips") {
Tips.showAllTipsForTesting()
}
#endif
try Tips.configure()Built-in launch arguments are also available:
-com.apple.TipKit.ResetDatastore 1-com.apple.TipKit.ShowAllTips 1-com.apple.TipKit.ShowTips TipTypeA,TipTypeB-com.apple.TipKit.HideAllTips 1
Testing override precedence is specific show, specific hide, show all, then hide all. Tips.resetDatastore() must run before Tips.configure(_:).
Common Mistakes
DON'T: Configure TipKit from a view
Configure during app initialization. View-level configuration can race with tip display and can also hit datastore-already-configured errors.
DON'T: Present iOS 18+ APIs as iOS 17 guidance
Gate TipGroup, CloudKit sync, and MaxDisplayDuration. Provide iOS 17 fallbacks with parameters/events only when the app still supports iOS 17. When the plan mentions TipGroup(.ordered), also call out that plain TipGroup defaults to .firstAvailable. Use this explicit review wording: "Plain TipGroup defaults to .firstAvailable; TipGroup(.ordered) is the iOS 18+ sequence mode where later tips wait for earlier invalidation."
DON'T: Use tips for critical information
Tips are dismissible and educational. Use alerts, confirmations, inline warnings, or blocking UI for safety, errors, data loss, and required steps.
DON'T: Ship testing overrides
showAllTipsForTesting() and related overrides bypass rules and frequency limits. Keep them behind #if DEBUG, test scheme arguments, or UI-test-only launch arguments.
DON'T: Use unstable reusable tip IDs
Tip IDs own persistence. If a reusable tip's ID changes unexpectedly, users can see duplicate or stale education.
Review Checklist
- [ ]
Tips.configure(_:)runs once during app initialization before tips display. - [ ]
Tips.resetDatastore()runs only before configuration and only for tests/debug. - [ ] iOS 18+ and iOS 26+ TipKit APIs have availability gates or fallback guidance.
- [ ] Tip copy is short, contextual, actionable, and not promotional.
- [ ] Inline vs popover presentation matches the surrounding UI flow.
- [ ] Rules target the intended audience and do not show every tip on first launch.
- [ ] Event IDs are stable, namespaced when shared, and donation payloads are small.
- [ ] Reusable tips override
idwith stable content-derived values. - [ ] Tips invalidate when the user performs the taught action.
- [ ]
TipGroupis stored in@State; reviews call out the default.firstAvailablepriority and use.orderedonly for true sequences with explicit invalidation, notMaxDisplayDurationas the sequencing mechanism. - [ ] CloudKit sync uses iCloud + CloudKit, Remote notifications, and a dedicated container when appropriate.
- [ ] Custom styles use
configurationvalues and callaction.label(). - [ ] Testing overrides are debug/test-only and never ship active in production.
References
- Read references/tipkit-patterns.md for complete implementation patterns: custom styles, event rules with donation values, TipGroup sequencing, CloudKit/app-group persistence, reusable IDs, previews, and test launch strategies.
- Apple TipKit docs: https://sosumi.ai/documentation/tipkit
- Apple
Tips.configure(_:): https://sosumi.ai/documentation/tipkit/tips/configure(_:) - Apple
TipGroup: https://sosumi.ai/documentation/tipkit/tipgroup - Apple HIG "Offering help": https://sosumi.ai/design/human-interface-guidelines/offering-help
- WWDC24 "Customize feature discovery with TipKit": https://sosumi.ai/videos/play/wwdc2024/10070
- WWDC23 "Make features discoverable with TipKit": https://sosumi.ai/videos/play/wwdc2023/10229
{
"skill_name": "tipkit",
"evals": [
{
"id": 0,
"name": "configuration-testing-review",
"prompt": "Review this TipKit setup for an iOS app: `Tips.configure()` is called in `ContentView.task`, UI tests call `Tips.resetDatastore()` after launch, the app ships `Tips.showAllTipsForTesting()` behind a custom runtime flag, and every tip uses `.displayFrequency(.immediate)` so onboarding is visible on first launch. Correct the plan with minimal Swift examples.",
"expected_output": "A correction-focused review that moves configuration to app initialization, resets before configure only in debug/test contexts, keeps testing overrides out of production, and recommends reasonable display frequency/rules instead of showing every tip immediately.",
"files": [],
"expectations": [
"States that `Tips.configure(_:)` should run once during app initialization before tips display, not from a view task or onAppear.",
"States that `Tips.resetDatastore()` must run before `Tips.configure(_:)` and only for debug, preview, or UI-test setup.",
"Keeps `showAllTipsForTesting()` and related overrides out of production builds.",
"Mentions built-in TipKit launch arguments or debug-gated custom launch arguments for UI tests.",
"Recommends rules and display frequency to avoid overwhelming first-launch users."
]
},
{
"id": 1,
"name": "tipgroup-cloudkit-availability",
"prompt": "We support iOS 17 and want a three-step TipKit coach-mark sequence that syncs dismissed tips across devices. The plan uses `TipGroup(.ordered)`, `.cloudKitContainer(.automatic)`, `MaxDisplayDuration(300)`, and says all of this is fine because TipKit is iOS 17+. Review the availability and setup risks.",
"expected_output": "A source-grounded review that separates iOS 17 core TipKit from iOS 18+ TipGroup, CloudKit sync, and MaxDisplayDuration, and gives appropriate fallback or gating guidance.",
"files": [],
"expectations": [
"Marks `TipGroup`, `.cloudKitContainer(...)`, and `MaxDisplayDuration` as iOS 18+ rather than iOS 17 core APIs.",
"Suggests iOS 17 fallback sequencing with parameters/events or availability gates.",
"Explains that `.ordered` waits for previous tips to be invalidated and that `TipGroup` defaults to `firstAvailable`.",
"Mentions CloudKit sync setup requirements: iCloud + CloudKit, Remote notifications, and an intentional container choice.",
"Recommends a dedicated `.tips` CloudKit container when syncing tip state."
]
},
{
"id": 2,
"name": "custom-style-reusable-id-review",
"prompt": "Review this TipKit custom UI plan: a reusable `NewItemTip` does not override `id`; a custom `TipViewStyle` reads properties from a concrete `NewItemTip` instance and renders `Button(action: action.handler) { action.label }`; event donations store a large model object; and the user action never invalidates the tip. What should change?",
"expected_output": "A review that corrects reusable tip identity, custom style configuration usage, action label invocation, event payload size, and invalidation behavior without drifting into unrelated SwiftUI layout guidance.",
"files": [],
"expectations": [
"Says reusable tips should override `id` with a stable content-derived identifier.",
"Uses `TipViewStyle.Configuration` values instead of reading directly from a concrete tip instance.",
"Calls `action.label()` and `action.handler` correctly inside custom style buttons.",
"Keeps `Event<DonationInfo>` payloads small and Codable/Sendable rather than storing large model objects.",
"Invalidates the tip when the user performs the discovered action."
]
}
]
}
TipKit Patterns Reference
Complete implementation patterns for TipKit including custom styles, event-based rules, tip groups, testing strategies, onboarding flows, and SwiftUI previews. Examples target iOS 17+ with Swift 6.3 conventions unless a section explicitly calls out a newer availability requirement.
Contents
- Complete Tip with Rules and Events
- TipView and popoverTip Placement
- Event-Based Rule with Donation Counting
- Custom TipViewStyle
- TipGroup Sequencing (iOS 18+)
- Testing Strategies
- Tip with Action Buttons
- Integration with Onboarding Flow
- Reusable Tip Identifiers
- CloudKit Sync (iOS 18+)
- Full App Integration Example
Availability Notes
- TipKit core APIs are iOS 17+.
TipGroup,.cloudKitContainer(...), andMaxDisplayDurationare iOS 18+.resetEligibility()is iOS 26+.Tips.resetDatastore()must run beforeTips.configure(_:).
Complete Tip with Rules and Events
A full-featured tip combining parameter-based and event-based rules. The tip appears only after the user has logged in and opened the app at least three times, ensuring they are familiar with the basics before seeing advanced feature discovery.
import TipKit
struct AdvancedSearchTip: Tip {
// Parameter rule: user must be logged in
@Parameter
static var isLoggedIn: Bool = false
// Event rule: user must have performed searches
static let searchPerformed = Tips.Event(id: "searchPerformed")
var title: Text {
Text("Try Advanced Search")
}
var message: Text? {
Text("Filter results by date, category, and location for faster discovery.")
}
var image: Image? {
Image(systemName: "magnifyingglass")
}
// All rules must pass before the tip becomes eligible
var rules: [Rule] {
#Rule(Self.$isLoggedIn) { $0 == true }
#Rule(Self.searchPerformed) { $0.donations.count >= 3 }
}
var options: [any TipOption] {
MaxDisplayCount(5)
}
}Donating to Events
Place event donations at the point where the user action occurs. Each donation increments the internal counter that rules evaluate against.
struct SearchView: View {
@State private var query = ""
var body: some View {
SearchBar(text: $query, onSubmit: {
performSearch(query)
// Donate each time the user searches
AdvancedSearchTip.searchPerformed.sendDonation()
})
}
}Setting Parameters
Set parameter values when the relevant app state changes. Parameters persist across launches via the TipKit datastore.
func handleLoginSuccess() {
AdvancedSearchTip.isLoggedIn = true
}TipView and popoverTip Placement
Inline TipView in a List
Place a TipView as a list row for contextual inline discovery. The tip appears as part of the list content and animates away when dismissed or invalidated.
struct ItemListView: View {
let filterTip = FilterTip()
@State private var items: [Item] = []
var body: some View {
List {
TipView(filterTip)
ForEach(items) { item in
ItemRow(item: item)
}
}
.navigationTitle("Items")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showFilters()
filterTip.invalidate(reason: .actionPerformed)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
.popoverTip(filterTip, arrowEdge: .top)
}
}
}
}Popover on Navigation Bar Button
Attach a popover tip to a toolbar button. The popover arrow points to the button, drawing the user's attention to the exact control.
struct EditorView: View {
let undoTip = UndoShortcutTip()
var body: some View {
TextEditor(text: $text)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Undo", systemImage: "arrow.uturn.backward") {
undoLastAction()
undoTip.invalidate(reason: .actionPerformed)
}
.popoverTip(undoTip, arrowEdge: .top)
}
}
}
}Popover on Tab Bar Item
Use popoverTip on a Tab label view inside a TabView to highlight a new tab.
struct MainTabView: View {
let newTabTip = NewFeatureTabTip()
var body: some View {
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Discover", systemImage: "sparkles") {
DiscoverView()
}
.popoverTip(newTabTip)
}
}
}Event-Based Rule with Donation Counting
Track how many times the user performs an action, then show a tip suggesting a more efficient alternative. This pattern is effective for progressive disclosure: let users learn the basic workflow first, then reveal shortcuts.
struct KeyboardShortcutTip: Tip {
static let manualSaveEvent = Tips.Event(id: "manualSave")
var title: Text {
Text("Save Faster with Command-S")
}
var message: Text? {
Text("Press Command-S instead of using the menu to save your work instantly.")
}
var image: Image? {
Image(systemName: "keyboard")
}
var rules: [Rule] {
// Show after user has manually saved 5 times via button
#Rule(Self.manualSaveEvent) { $0.donations.count >= 5 }
}
}
struct DocumentView: View {
let shortcutTip = KeyboardShortcutTip()
var body: some View {
VStack {
TipView(shortcutTip)
DocumentEditor(document: $document)
}
.toolbar {
ToolbarItem {
Button("Save") {
saveDocument()
KeyboardShortcutTip.manualSaveEvent.sendDonation()
}
}
}
}
}Event Donations with Associated Values
Attach a DonationValue to event donations for richer rule evaluation. Use Codable-conforming types to provide context about each donation.
struct DetailedTip: Tip {
struct DonationInfo: Codable, Sendable {
let category: String
let timestamp: Date
}
static let itemViewed = Tips.Event<DonationInfo>(id: "itemViewed")
var rules: [Rule] {
#Rule(Self.itemViewed) {
$0.donations.filter {
$0.category == "premium"
}.count >= 3
}
}
var title: Text { Text("Unlock Premium Content") }
}
// Donate with associated value
DetailedTip.itemViewed.sendDonation(
DetailedTip.DonationInfo(category: "premium", timestamp: .now)
)Custom TipViewStyle
Create a branded tip appearance that matches the app's design language. The Configuration provides access to the tip's title, message, image, and actions.
struct BrandedTipStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .top) {
configuration.image?
.font(.system(size: 24))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading) {
configuration.title?
.font(.headline)
configuration.message?
.font(.subheadline)
.foregroundStyle(.secondary)
if !configuration.actions.isEmpty {
HStack {
ForEach(configuration.actions) { action in
Button(action: action.handler) {
action.label()
.font(.subheadline.bold())
}
.buttonStyle(.bordered)
}
}
.padding(.top)
}
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}Applying the Custom Style
Apply the style to individual TipView instances or set it as the environment default.
// Per view
TipView(myTip)
.tipViewStyle(BrandedTipStyle())
// Environment-wide (apply to a parent container)
NavigationStack {
ContentView()
}
.tipViewStyle(BrandedTipStyle())Minimal Compact Style
A stripped-down style for tips in tight layouts like toolbars or sidebars.
struct CompactTipStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.image?
.foregroundStyle(.tint)
configuration.title?
.font(.caption.bold())
}
.padding(.horizontal)
.padding(.vertical)
.background(.tint.opacity(0.1), in: Capsule())
}
}TipGroup Sequencing (iOS 18+)
Use TipGroup to present related tips. TipGroup defaults to .firstAvailable, which shows the first eligible tip in the group without requiring a strict sequence. Pass .ordered only when each later tip must wait until all previous tips have been invalidated.
For ordered coach-mark flows, only the current tip displays. When the user dismisses or acts on it, invalidate that tip so the next tip in the group can become current.
struct OnboardingTipA: Tip {
var title: Text { Text("Welcome to the App") }
var message: Text? { Text("Let's take a quick tour of the main features.") }
var image: Image? { Image(systemName: "hand.wave") }
}
struct OnboardingTipB: Tip {
var title: Text { Text("Browse Your Feed") }
var message: Text? { Text("Swipe through curated content tailored for you.") }
var image: Image? { Image(systemName: "rectangle.stack") }
}
struct OnboardingTipC: Tip {
var title: Text { Text("Customize Your Profile") }
var message: Text? { Text("Tap your avatar to set your name and preferences.") }
var image: Image? { Image(systemName: "person.crop.circle") }
}
struct HomeView: View {
@State private var tipGroup = TipGroup(.ordered) {
OnboardingTipA()
OnboardingTipB()
OnboardingTipC()
}
var body: some View {
VStack {
if let currentTip = tipGroup.currentTip {
TipView(currentTip) { action in
currentTip.invalidate(reason: .actionPerformed)
}
}
FeedView()
}
.padding()
}
}Tip Group with Popover
Attach the group's current tip as a popover that moves between controls as tips advance.
struct ToolbarGroupView: View {
@State private var group = TipGroup(.ordered) {
SearchTip()
FilterTip()
SortTip()
}
var body: some View {
HStack {
Button("Search", systemImage: "magnifyingglass") { search() }
.popoverTip(group.currentTip as? SearchTip)
Button("Filter", systemImage: "line.3.horizontal.decrease") { filter() }
.popoverTip(group.currentTip as? FilterTip)
Button("Sort", systemImage: "arrow.up.arrow.down") { sort() }
.popoverTip(group.currentTip as? SortTip)
}
}
}Testing Strategies
Previewing Tips in SwiftUI Previews
Configure TipKit in the preview body so tips display in Xcode previews. Use showAllTipsForTesting() to bypass rules. Reset the datastore before configuration; Tips.resetDatastore() must not run after Tips.configure().
#Preview {
ContentView()
.task {
try? Tips.resetDatastore()
Tips.showAllTipsForTesting()
try? Tips.configure([.displayFrequency(.immediate)])
}
}Previewing a Specific Tip
Show only one tip in a focused preview.
#Preview("Favorite Tip") {
VStack {
TipView(FavoriteTip())
Spacer()
}
.padding()
.task {
try? Tips.resetDatastore()
Tips.showTipsForTesting([FavoriteTip.self])
try? Tips.configure([.displayFrequency(.immediate)])
}
}Unit Testing Tip Rules
Verify that parameter and event rules correctly control tip eligibility. Reset the datastore before each test to ensure a clean state. TipKit configuration is process-level, so prefer isolated UI-test launches for full lifecycle coverage. If using unit tests, reset before configuring.
import XCTest
import TipKit
final class TipRuleTests: XCTestCase {
override func setUp() async throws {
try Tips.resetDatastore()
try Tips.configure([.displayFrequency(.immediate)])
}
func testAdvancedSearchTipRequiresLogin() async {
let tip = AdvancedSearchTip()
// Tip should not be eligible before login
AdvancedSearchTip.isLoggedIn = false
// Verify tip status
// Tip should become eligible after login + enough events
AdvancedSearchTip.isLoggedIn = true
for _ in 0..<3 {
AdvancedSearchTip.searchPerformed.sendDonation()
}
// Verify tip status
}
func testTipInvalidation() async {
let tip = FavoriteTip()
tip.invalidate(reason: .actionPerformed)
// Tip should no longer be eligible after invalidation
}
}UI Testing with Forced Tips
Pass launch arguments to control tip visibility in UI tests. This ensures tests that verify tip UI always see the tip, regardless of rules.
// In UI test setUp
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.TipKit.ResetDatastore", "1",
"-com.apple.TipKit.ShowAllTips", "1"
]
app.launch()// Optional custom wrappers in App.init
init() {
if ProcessInfo.processInfo.arguments.contains("--show-all-tips") {
Tips.showAllTipsForTesting()
}
if ProcessInfo.processInfo.arguments.contains("--hide-all-tips") {
Tips.hideAllTipsForTesting()
}
try? Tips.configure()
}UI Testing Without Tips
Suppress all tips in UI tests that are not about tip behavior, so tips do not interfere with other test flows.
// In UI test setUp for non-tip tests
let app = XCUIApplication()
app.launchArguments += ["-com.apple.TipKit.HideAllTips", "1"]
app.launch()Tip with Action Buttons
Add action buttons that deep-link to a feature. Invalidate the tip when the user taps the primary action.
struct NewEditorTip: Tip {
var title: Text {
Text("Try the New Editor")
}
var message: Text? {
Text("A faster, more powerful editing experience awaits.")
}
var image: Image? {
Image(systemName: "pencil.and.outline")
}
var actions: [Action] {
Action(id: "open-editor", title: "Open Editor")
Action(id: "later", title: "Maybe Later")
}
}
struct HomeView: View {
let editorTip = NewEditorTip()
@State private var showEditor = false
var body: some View {
VStack {
TipView(editorTip) { action in
switch action.id {
case "open-editor":
showEditor = true
editorTip.invalidate(reason: .actionPerformed)
case "later":
editorTip.invalidate(reason: .tipClosed)
default:
break
}
}
MainContentView()
}
.sheet(isPresented: $showEditor) {
EditorView()
}
}
}Integration with Onboarding Flow
Coordinate TipKit with a first-run onboarding flow. Invalidate welcome tips after the user completes onboarding so they do not see redundant information.
struct WelcomeTip: Tip {
@Parameter
static var hasCompletedOnboarding: Bool = false
var title: Text { Text("Welcome to MyApp") }
var message: Text? { Text("Swipe through to learn the basics.") }
var rules: [Rule] {
// Only show if onboarding was NOT completed (user skipped it)
#Rule(Self.$hasCompletedOnboarding) { $0 == false }
}
}
struct FeatureDiscoveryTip: Tip {
@Parameter
static var hasCompletedOnboarding: Bool = false
var title: Text { Text("Discover Collections") }
var message: Text? { Text("Organize your items into collections for easy access.") }
var rules: [Rule] {
// Only show after onboarding completes
#Rule(Self.$hasCompletedOnboarding) { $0 == true }
}
}
struct OnboardingView: View {
@Binding var isPresented: Bool
var body: some View {
VStack {
// Onboarding pages...
Button("Get Started") {
completeOnboarding()
}
}
}
func completeOnboarding() {
// Invalidate welcome tips since onboarding covered the basics
WelcomeTip.hasCompletedOnboarding = true
FeatureDiscoveryTip.hasCompletedOnboarding = true
// Explicitly invalidate any welcome-specific tips
let welcomeTip = WelcomeTip()
welcomeTip.invalidate(reason: .actionPerformed)
isPresented = false
}
}
struct ContentView: View {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@State private var showOnboarding = false
let welcomeTip = WelcomeTip()
let discoveryTip = FeatureDiscoveryTip()
var body: some View {
NavigationStack {
VStack {
TipView(welcomeTip)
CollectionGrid()
.popoverTip(discoveryTip)
}
}
.sheet(isPresented: $showOnboarding) {
OnboardingView(isPresented: $showOnboarding)
}
.onAppear {
if !hasCompletedOnboarding {
showOnboarding = true
}
}
}
}Reusable Tip Identifiers
Override id when one reusable Tip type should create separate persisted records for different content. The ID controls status, display count, rules, and invalidation state.
struct NewCollectionTip: Tip {
let collection: CollectionSummary
var id: String {
"NewCollectionTip-\(collection.id)"
}
var title: Text {
Text("Explore \(collection.name)")
}
var message: Text? {
Text("A new collection is ready for browsing.")
}
}
struct CollectionListView: View {
let latestCollection: CollectionSummary
var body: some View {
TipView(NewCollectionTip(collection: latestCollection))
CollectionGrid()
}
}Use stable model identifiers. Do not use localized copy, array indexes, dates that change every launch, or random values.
CloudKit Sync (iOS 18+)
CloudKit sync shares TipKit status, rules, parameters, events, display counts, and display duration across devices signed into the same iCloud account.
Project setup:
- Enable iCloud and CloudKit in Signing & Capabilities.
- Enable Background Modes > Remote notifications.
- Prefer a dedicated CloudKit container with a
.tipssuffix.
@main
struct SyncedTipsApp: App {
init() {
do {
try Tips.configure([
.cloudKitContainer(.named("iCloud.com.example.MyApp.tips")),
.displayFrequency(.daily)
])
} catch {
assertionFailure("TipKit configuration failed: \(error)")
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Use .cloudKitContainer(.automatic) only when the entitlement list is deliberately arranged so TipKit can choose the intended container. When sharing tip state across app-group members or devices, keep option settings consistent for the same tip IDs.
Full App Integration Example
A complete example showing TipKit configuration, multiple tips with rules, event donations, and proper invalidation.
import SwiftUI
import TipKit
// MARK: - Tips
struct SearchTip: Tip {
var title: Text { Text("Search Your Library") }
var message: Text? { Text("Tap to find any item by name, tag, or date.") }
var image: Image? { Image(systemName: "magnifyingglass") }
}
struct CollectionTip: Tip {
static let itemAddedEvent = Tips.Event(id: "itemAdded")
var title: Text { Text("Create a Collection") }
var message: Text? { Text("Group related items together for quick access.") }
var image: Image? { Image(systemName: "folder.badge.plus") }
var rules: [Rule] {
#Rule(Self.itemAddedEvent) { $0.donations.count >= 3 }
}
}
struct ShareTip: Tip {
@Parameter
static var hasCreatedCollection: Bool = false
var title: Text { Text("Share Your Collection") }
var message: Text? { Text("Invite others to view or collaborate on your collection.") }
var image: Image? { Image(systemName: "square.and.arrow.up") }
var rules: [Rule] {
#Rule(Self.$hasCreatedCollection) { $0 == true }
}
}
// MARK: - App
@main
struct LibraryApp: App {
init() {
do {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--reset-tips") {
try Tips.resetDatastore()
}
if ProcessInfo.processInfo.arguments.contains("--show-all-tips") {
Tips.showAllTipsForTesting()
}
if ProcessInfo.processInfo.arguments.contains("--hide-all-tips") {
Tips.hideAllTipsForTesting()
}
#endif
try Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
} catch {
assertionFailure("TipKit configuration failed: \(error)")
}
}
var body: some Scene {
WindowGroup { LibraryView() }
}
}
// MARK: - Main View
struct LibraryView: View {
let searchTip = SearchTip()
let collectionTip = CollectionTip()
let shareTip = ShareTip()
@State private var items: [LibraryItem] = []
var body: some View {
NavigationStack {
List {
TipView(collectionTip)
ForEach(items) { item in
Text(item.name)
}
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Search", systemImage: "magnifyingglass") {
showSearch()
searchTip.invalidate(reason: .actionPerformed)
}
.popoverTip(searchTip)
}
ToolbarItem(placement: .secondaryAction) {
Button("Share", systemImage: "square.and.arrow.up") {
shareCollection()
shareTip.invalidate(reason: .actionPerformed)
}
.popoverTip(shareTip)
}
ToolbarItem(placement: .secondaryAction) {
Button("Add Item", systemImage: "plus") {
addItem()
CollectionTip.itemAddedEvent.sendDonation()
}
}
}
}
}
func addItem() { /* ... */ }
func showSearch() { /* ... */ }
func shareCollection() { /* ... */ }
}Related skills
How it compares
Pick tipkit for native Apple TipKit with rules, events, and TipGroups; use custom SwiftUI overlays when TipKit’s iOS 17 minimum or donation model does not fit.
FAQ
Where should Tips.configure run?
Once during app initialization in the App init, not from a view onAppear or task modifier.
When should I use ordered TipGroup?
Only for sequences where later tips wait for earlier invalidation; default is firstAvailable.
Does CloudKit sync work on iOS 17?
No. cloudKitContainer and CloudKit tip sync require iOS 18 plus with iCloud and remote notifications enabled.
Is Tipkit safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.