TipKit: Things to know before using popoverTip()
The usecase
I make Fasty an intermittent fasting app. The app is quite simple, with two tabs. The first one is for fasting and looks like this:
One of the most common support requests I receive is that users want to edit the time when they started their fast. Most users won’t tap the start fast button in the exact right moment, but later. Maybe you discover the next day that you forgot to start it. You want to be able to correct the data.
This is done by tapping the “Edit Start” button, which opens a simple sheet where you can update start the time. But if you’re new to the app, you need to discover this feature first. So this is an UX problem. It’s been on my list to solve this with TipKit.
There are many ways to improve this. One issue is the minimal UI, which doesn’t show the edit start button, when not fasting. But this article is about TipKit.
A brief introduction to TipKit
TipKit was announced at WWDC 2023 with iOS 17. It aims to solve feature discovery by adding short, actionable, memorable & educational tips at the right place and time in the app. Nobody wants to be spammed with tips, when using an app the first time or receive tips for things you already know and use. TipKit has a sophisticated rule engine to show tips in the right context. The WWDC example is about an user that navigated a few times to a detail view, but never interacted with the favorite button.
Depending on the platform there’s two types of tips. There’s TipView to display a tip as view integrated in the screen. This can work well in a scrollable UI to e.g. add some tip at the top.
And there are popover tips, which present a small popover over the UI pointing with an arrow to the presenting View.
In my app, there’s no scrollable list for the fasting tab. It makes the most sense to present a popover from the edit start button.
Popover tips are navigation
The most important thing to understand is that popover tips are navigation. What do I mean by that? In SwiftUI, there are many navigation APIs that allow users to navigate to different destinations (e.g. NavigationStack
and NavigationSplitView
) and modals (e.g. sheet
, fullscreenCover
, alert
and popover
).
The last modal API has already almost the same name as TipKit’s popoverTip
.
There are some important rules for navigation APIs. For example, you can only present one sheet or popover any time. SwiftUI can’t present two sheets from the same View.
struct ContentView: View {
@State private var first = true
@State private var second = true
var body: some View {
Text("Two Sheets")
.sheet(isPresented: $first) {
Color.red
}
.sheet(isPresented: $second) {
Color.blue
}
}
}
This SwiftUI code causes undetermined behavior. Should SwiftUI open the red or blue sheet first? You’ll also get warnings like:
Currently, only presenting a single sheet is supported.
The next sheet will be presented when the currently presented sheet gets dismissed.
It’s therefore best practice to model destination state as an enum as only one destination case can be active at any time. This is discussed at length by PointFree. They also offer the nice swift-navigation open source library to make this easy.
What matters here is that the same applies for TipKit’s popoverTip
. Only one popover tip can be visible at any time. The rule engine may determine that multiple tips are eligible for presentation, but only one is shown. And it’s not just tips. The actual rule is that only one modal destination can be active including sheet
, fullScreenCover
alert
and so on.
If we look at the UIKit implementation, we can also verify this:
let popoverController = TipUIPopoverViewController(catTracksFeatureTip, sourceItem: catTracksFeatureButton)
present(popoverController, animated: animated)
Source: Apple Documentation
popoverTip() vs popover() API
The popover
function signature starts like this:
func popover<Item, Content>(item: Binding<Item?>, ...)
The binding controls the presentation. A non-nil value presents the popover and a nil value dismisses the popover.
The simple version just takes a Bool Binding where true presents the popover and false dismisses it.
func popover<Content>(isPresented: Binding<Bool>, ...)
In both cases, this keeps your @Observable
model or @State
in sync with the UI. And it allows you to programmatically present and dismiss.
TipKit launched with iOS 17 with the following API:
func popoverTip<Content: Tip>(_ tip: Content, ...)
Note the differences. There’s no Binding, no Bool and nothing Optional.
For iOS 18 there’s this new additional API:
func popoverTip(_ tip: (any Tip)?, ...)
Now the tip is Optional, but still no Binding or Boolean. So the popoverTip API is completely different than all other SwiftUI navigation APIs. What are the consequences of this API design?
Problems with popoverTip()
There’s multiple problems that result of this API. You can also find related Apple developer forum posts.
https://forums.developer.apple.com/forums/thread/751269 https://forums.developer.apple.com/forums/thread/749853 https://forums.developer.apple.com/forums/thread/745541
No programmatic dismiss or present
The popoverTip can be dismissed by the user by tapping outside the view. But as there’s no Binding SwiftUI / TipKit can’t nil out the state. There’s nothing informing you that the user dismissed the tip.
Maybe you want to present another tip or a sheet after a tip is presented. If one just presents the sheet optimistically the tip will be automatically dismissed. Maybe the user only had milliseconds to glimpse at it.
Or perhaps you just want to do some logging how often a tip is dismissed.
Undetermined behaviour
struct ATip: Tip {
var title: Text {
Text("A")
}
}
struct BTip: Tip {
var title: Text {
Text("B")
}
}
struct ContentView: View {
var body: some View {
VStack {
Text("A")
.popoverTip(ATip())
Text("B")
.popoverTip(BTip())
}
.task {
try? Tips.resetDatastore()
try? Tips.configure([.displayFrequency(.immediate)])
}
}
}
First, ATip is shown. But also, after dismissing, BTip isn’t shown even though it’s eligible and the displayFrequency is immediate. Maybe TipKit tried to present it as it became eligible, but internally detected already a visible tip. And TipKit apparently also doesn’t internally check for dismissals.
Tips are presented out of context
Without state synchronized to the navigation state it’s very easy to end up in a case where a popoverTip and a modal is visible. This results in a very bad user experience. The user may think she can perform the tip’s feature in the modal, but actually it’s in the modal presenting view. Instead of helping with discovery the tip hurts discovery.
It depends on timing you don’t control, whether a tip, a sheet or both will be presented.
A tip is being shown pointing to a button that’s covered by a sheet. This tip is confusing and completely out of context.
struct ContentView: View {
@State private var sheet = true
var body: some View {
VStack {
Text("A")
.popoverTip(ATip())
Text("B")
.popoverTip(BTip())
}
.sheet(isPresented: $sheet) {
Color.orange
}
.task {
try? Tips.resetDatastore()
try? Tips.configure([ .displayFrequency(.immediate)])
}
}
}
Attempt to present <TipKit.TipUIPopoverViewController: 0x10630c940> on <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x106855a00> (from <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x106855a00>) while a presentation is in progress.
Optional tip state change maybe does something or not
The following examples only work with the iOS 18 API so it won’t be useful as most apps have to support iOS 16/17 and later.
struct ContentView: View {
@State private var aTip: ATip?
@State private var bTip: BTip?
var body: some View {
VStack {
Text("A")
.popoverTip(aTip)
Text("B")
.popoverTip(bTip)
}
.onAppear {
try? Tips.resetDatastore()
try? Tips.configure([ .displayFrequency(.immediate)])
aTip = ATip()
}
}
}
This shows the aTip only as expected.
We can add some code at the end of the onAppear
:
Task { @MainActor in
try await Task.sleep(for: .seconds(2))
bTip = BTip()
}
This does nothing as ATip is presented already. If the user dismisses ATip before the two seconds elapse BTip will be presented. But again there’s no way within the SwiftUI API to be informed, if the user already dismissed ATip by tapping outside.
Task { @MainActor in
try await Task.sleep(for: .seconds(2))
aTip = nil
bTip = BTip()
}
If ATip is set to nil it works.
Task { @MainActor in
try await Task.sleep(for: .seconds(2))
bTip = BTip()
try await Task.sleep(for: .seconds(2))
aTip = nil
}
If ATip is set to nil at a later time it will dismiss ATip, but BTip won’t be presented now.
Potential workaround: Do Your Own popoverTip()
The only SwiftUI based solution I can come up with is using the existing popover()
modifier with some custom MyTipView
. This only works, because of .presentationCompactAdaptation(.none)
as by default popover()
renders as sheet in compact size classes like on iPhone devices. It’s also possible to use .presentationCompactAdaptation(.popover)
. Without this API you need to wrap UIKit to present the popover.
The code below is just a quick proof of concept. It’s not a pixel-perfect and production-tested reimplementation. I used to have a lot of issues with popover()
with iOS 13 and iOS 14. I don’t know, if this works well now for iOS 17 and higher. While working on this in a preview the tip was sometimes presented as a sheet despite .presentationCompactAdaptation()
, which you never want to happen. I didn’t investigate, if this was a preview only bug or can happen in production apps as well.
struct ContentView: View {
var body: some View {
VStack {
Text("A")
.popover(isPresented: .constant(true)) {
MyTipView(ATip())
.presentationCompactAdaptation(.none)
.presentationBackground(.ultraThinMaterial.opacity(0.3))
}
Spacer()
.frame(height: 400)
Text("B")
.popoverTip(BTip())
Spacer()
}
}
}
struct MyTipView<T: Tip>: View {
private let tip: AnyTip
init(_ tip: T) {
self.tip = AnyTip(tip)
}
var body: some View {
HStack(alignment: .top) {
tip.image
.foregroundStyle(.tint)
.font(.system(size: 45))
VStack(alignment: .leading) {
HStack {
tip.title
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Close", systemImage: "xmark") {
tip.invalidate(reason: .tipClosed)
}
.fontWeight(.semibold)
.labelStyle(.iconOnly)
.foregroundStyle(.tertiary)
}
tip.message
.font(.subheadline)
.foregroundStyle(.secondary)
if !tip.actions.isEmpty {
ForEach(tip.actions) { action in
Divider()
.padding(.trailing, -16)
Button(action: action.handler, label: action.label)
// TODO: There's no TipKit documentation for when the index is nil.
.fontWeight(action.index ?? 0 == 0 ? .semibold : .regular)
}
}
}
.fixedSize(horizontal: false, vertical: true)
}
// TODO: No magic numbers
.padding(.vertical, 20)
.padding(.horizontal, 16)
// TODO: This frame is just for similar widths in the screenshot.
.frame(maxWidth: 320)
}
}
You probably want to make a dedicated API like popoverTip<T: Tip>(_ tip: Binding<T?>) -> some View
. Note also that such a tip won’t automatically present. But the automatic presentation is also what causes the issues. Instead you need to observe and act on state changes in your model or View
. It’s more work, but it means you’re in control and the model state remains in sync with what’s visible in the UI.
@Observable
final class Model {
var tip: ATip?
/// Present tip as it becomes available for presentation
@MainActor
func observeTipStatus() async {
let tip = ATip()
for await shouldDisplay in tip.shouldDisplayUpdates { [weak self] in
guard shouldDisplay else { continue }
// Check that no other tip or another modal is presented
// Maybe check that there's no other work in-flight that may cause navigation for the result
self?.tip = tip
}
}
/// Called when modals are dismissed.
@MainActor
func onDismiss() {
let tip = ATip()
// Present eligible tip after modal was dismissed
guard tip.shouldDisplay else { return }
self.tip = tip
}
}
Conclusion
The bottom line is that the popoverTip()
API adds uncontrollable and somewhat random modal presentations and dismissals to you app. The side effects of that are likely to cause issues in any non-trivial app especially with other modals and navigation.
For my fasting app I’ll have to find a different solution. I don’t want to deal with the undetermined behavior and additional support tickets due to broken tips.
Time will tell, if and what Apple fixes in future OS releases. It will probably not back-deploy, as I don’t see how this would work without more navigation like APIs using Binding
. So I don’t expect popoverTip() to be usable for apps with minimum deployment targets lower than iOS 19.
Extra: API availability
Talking about API availability and deployment targets some SwiftUI APIs are available on older OS releases, but do nothing.
For example the contentMarginsDisabled() documentation states:
This modifier has no effect on operation system versions prior to iOS 17, watchOS 10, or macOS 14.
For TipKit it’s not as easy as it introduces new types. But, if SDK users can define shims as in the example below why can’t Apple do that as well? It would make adoption of new APIs so much easier. And one could still support updates to users on older OS versions. They just won’t get new features like tips.
@available(iOS, obsoleted: 17, message: "Removed once we only support iOS 17+")
public protocol TipShim {
@available(iOS 17, *)
var tip: AnyTip { get }
}
@available(iOS 17, *)
public extension Tip where Self: TipShim {
public var tip: AnyTip { AnyTip(self) }
}
@available(iOS, obsoleted: 17, message: "Remove once we only support iOS 17+")
public struct AnyTipAction {
public init(anyAction: Any) {
self.anyAction = anyAction
}
public let anyAction: Any
@available(iOS 17, *)
public var action: Tips.Action {
anyAction as! Tips.Action
}
}
extension View {
@available(iOS, deprecated: 17.0)
@available(watchOS, unavailable)
@MainActor @ViewBuilder
public func _popoverTip<Content>(
_ tip: Content?,
arrowEdge: Edge = .top,
actionHandler: @escaping (AnyTipAction) -> Void = { _ in }
) -> some View where Content : TipShim {
if #available(iOS 18.0, *) {
self.popoverTip(tip?.tip, arrowEdge: arrowEdge, action: { action in
actionHandler(AnyTipAction(anyAction: action))
})
} else if #available(iOS 17.0, *), let tip {
self.popoverTip(tip.tip, arrowEdge: arrowEdge, action: { action in
actionHandler(AnyTipAction(anyAction: action))
})
} else {
self
}
}
}
Adapted from a StackOverflow answer by Frank Rupprecht.
Unfortunately Apple doesn’t seem to propose such a solution for questions in the Apple developer forum.
Extra: Some more technical details
You can be notified, of a dismiss where the user taps on the x button via the var statusUpdates: AsyncStream<Self.Status> { get }
API on Tip
. You’ll receive Tips.InvalidationReason.tipClosed
. This is a completely different API then how dismissal for other modals works. But this doesn’t work for taps outside of the popover, which dismisses without a status change.
You can technically try to use Tip Parameters to sync whether a modal is shown.
struct ATip: Tip {
static var modalIsShown: Bool = true
var rules: [Rule] {
#Rule(Self.$modalIsShown) {
$0 == false
}
}
}
But this requires updating this extra state anytime a modal is presented or dismissed. The initial value of the static variable can’t be set correctly. You can only pessimistically expect a modal and update it at app launch, if there’s none. And this only works the one direction to only present a tip, if no other modal is presented. It doesn’t work for only presenting a tip, if no other modal or tip is presented. As again there’s no definitive way to know, if a tip is visible or dismissed. So this workaround doesn’t really work.
It’s probably possible to use TipKit via UIKit and do runtime checks for things like whether a TipKit.TipUIPopoverViewController
is presented. But, if you’ve to resort to UIKit and runtime checks it just means the SwiftUI API is broken and badly designed.