Mobile Dev

TipKit Dismissal: Tapping Outside Permanent?

Understand TipKit's expected dismissal behavior in SwiftUI: tapping outside dismisses visually but doesn't mark as displayed. Learn permanent dismissal patterns with MaxDisplayCount, Rules, Events, and gestures for iOS 17+ apps.

1 answer 1 view

Can a TipKit tip be permanently dismissed (marked as displayed) by tapping outside the tip?

Context:

  • Using TipKit (iOS 17+) in a SwiftUI app with a protocol-based wrapper for iOS 16 compatibility.
  • Attaching tips via a custom modifier, e.g. Text("Hello, World!").popupTip(SampleTip()).
  • TipKit configured in app startup with .displayFrequency(.immediate), e.g.:
swift
.task {
 if #available(iOS 17, *) {
 try? Tips.configure([
 .datastoreLocation(.applicationDefault),
 .displayFrequency(.immediate)
 ])
 }
}

Observed behavior:

  • Dismissing a tip with the tip’s X (close) button marks it as displayed and prevents it from showing again.
  • Tapping outside the tip dismisses it visually but the tip keeps reappearing later (it is not marked as displayed).

Questions:

  1. Is it expected/intentional behavior that tapping outside a TipKit popup does not mark the tip as displayed?
  2. Is there a supported API or recommended pattern to treat an outside tap as a permanent dismissal (i.e., mark the tip as displayed so it won’t show again)?
  3. If this is not intended, what could be wrong with my setup and how can I detect or programmatically mark the tip as displayed when the user taps outside?

Yes, tapping outside a TipKit tip is expected behavior—it only dismisses the tip visually without marking it as permanently displayed, unlike the X button which invalidates it for good. This keeps things non-committal; users can peek and bail without committing to “I’ve seen this.” For permanent dismissal on outside taps in SwiftUI, lean on MaxDisplayCount(1), custom Rules with Events, or overlay gestures to programmatically call invalidate()—all supported patterns straight from Apple’s docs and community fixes.


Contents


Expected TipKit Dismissal Behavior

Ever notice how TipKit tips vanish smoothly when you tap elsewhere, only to pop back up later? That’s no glitch. Apple’s design treats an outside tap as temporary dismissal—purely visual, no update to the tip’s datastore. The X button? That’s your commitment button. It marks the tip as displayed, wiping it from future eligibility.

Why split hairs like this? Think user intent. A quick glance outside might mean “not now,” not “never again.” Apple’s official TipKit docs spell it out: popoverTip() handles outside taps for immediate closure, but permanent marking needs explicit action or code. Tapping the X donates to the system, updating display history. Outside? Nada. It respects your .displayFrequency(.immediate) setup too—tips re-evaluate on relaunch or trigger, but won’t auto-invalid ate on casual swipes.

In practice, this shines for onboarding flows. Users skim a tip about your app’s killer feature, tap away, and it nags gently next time. Matches your observation perfectly: X works forever, outside tap doesn’t. Forums confirm it’s baked-in, even in UIKit/SwiftUI hybrids. No iOS 17 bug here—just intentional smarts.

But what if you want outside taps to stick? Hold that thought.


Permanent Dismissal Patterns for Outside Taps

Craving permanent dismissal on those sneaky outside taps? TipKit doesn’t hand you a switch for it out of the box. No built-in callback screams “user tapped away!” Still, devs have solid workarounds. Let’s break 'em down, starting simple.

First up: MaxDisplayCount. Slap this on your tip definition. Show once, done.

swift
struct SampleTip: Tip {
 var title: Text { Text("Hello, World!") }
 var message: Text? { Text("Tap outside? Gone forever.") }
 
 var options: [TipOption] {
 [MaxDisplayCount(1)] // One and done
 }
}

Attach via your custom modifier—boom, even outside taps count toward that limit. Reappears? Nope. Caveat: If users ignore it entirely, it might linger until the count hits. Pairs great with .immediate frequency.

Need more control? Rules and Events. Donate a custom Event on outside tap detection, then rule against re-showing. Apple’s docs push this for nuanced logic.

swift
// Custom event
struct OutsideTapEvent: TipKit.Event { }

// In your tip
struct SampleTip: Tip {
 // ... existing stuff
 var rules: [TipKit.Rule] {
 [#Rule(OutsideTapEvent) { $0.donations.count == 0 }]
 }
}

How to detect the tap? Wrap your view in a ZStack with a full-screen Color.clear overlay. Gesture it.

swift
ZStack {
 Text("Hello, World!")
 .popupTip(SampleTip())
 Color.clear
 .contentShape(Rectangle())
 .onTapGesture {
 Tips.donate(OutsideTapEvent()) // Marks for invalidation
 SampleTip().invalidate(reason: .displayed(in: .council(.standard))) // Or direct invalidate
 }
 .allowsHitTesting(false) // Don't block underlying taps
}

Pro tip: Use @Parameter for state tracking if Rules feel heavy. Or chain .onChange(of: tip.isDisplayed)—though it’s finicky for outside events, per Stack Overflow threads.

For your protocol wrapper and iOS 16 compat? These play nice. Test with Tips.resetAllDatastore() or Tips.showAllTipsForTesting() in simulator. CloudKit sync? Add .cloudKitContainer to config. Expert blogs swear by custom TipViewStyle for overlays too—fancy, but overkill for most.

Short version: Yes, supported patterns exist. Pick your poison: count-based, event-driven, or gesture-hacked.


Troubleshooting Your SwiftUI TipKit Setup

Setup looks solid—.applicationDefault datastore, .immediate frequency, custom modifier. But if tips reappear wonky beyond outside taps, what’s up? Rarely a config flub, but let’s debug.

Common gotchas:

  1. iOS version guards. Your .task checks #available(iOS 17)—good. But wrapper protocol? Ensure it nil-checks Tips on iOS 16. No config = defaults kick in, which might skew frequency.

  2. Datastore persistence. .applicationDefault sticks across launches. Test reset: try? Tips.resetDatastore(named: nil). If tips ghost, nuke via Xcode debugger.

  3. Modifier stacking. popupTip on Text? Clean. But nesting views? Overlays can swallow taps. Use .allowsHitTesting(true) on tip views.

  4. Frequency quirks. .immediate prioritizes fresh tips. Combine with priority(.high) or Eligibility(.always)? Might override dismissals. Dial to .atMostOne for sanity.

From Apple forums, hybrid apps see fullScreenCover dismissals on X taps—simulator bug, not yours. Outside taps never invalidate by design.

Programmatic fix-all? Hook invalidate(reason: .displayed) post-dismissal. Track via TipState binding if your wrapper exposes it.

swift
@State private var sampleTipState = TipState(SampleTip.self)

var body: some View {
 Text("Hello")
 .popupTip(SampleTip(), tipState: $sampleTipState)
 .onChange(of: sampleTipState.isCurrentlyDisplaying) { isShowing in
 if !isShowing {
 Task {
 try? await SampleTip().invalidate()
 }
 }
 }
}

Won’t catch pure outside taps perfectly—gestures fill that gap. Run on device; sims lie sometimes.

If it’s still reappearing post-X? Datastore corruption. Reset app, reconfigure. 99% chance: all good, just lean into the behavior.


Sources

  1. Apple Developer Forums: Using TipKit in a UIKit/SwiftUI hybrid app
  2. Stack Overflow: Can a TipKit tip be permanently dismissed by tapping outside the tip?
  3. Apple Developer Documentation: Highlighting app features with TipKit
  4. TipKit: Things to know before using popoverTip()

Conclusion

TipKit’s outside tap dismissal is spot-on by design—temporary, user-friendly, and easy to override with MaxDisplayCount, Events, or gestures for permanent behavior. Your setup’s fine; tweak for control if needed. Nail this, and your SwiftUI onboarding feels polished, not pushy. Experiment—users thank you later.

Authors
Verified by moderation
Moderation
TipKit Dismissal: Tapping Outside Permanent?