Skip to content

Instantly share code, notes, and snippets.

@gerdemb
Last active December 12, 2025 09:24
Show Gist options
  • Select an option

  • Save gerdemb/045a86d275ddb655c62e9ea80e76b189 to your computer and use it in GitHub Desktop.

Select an option

Save gerdemb/045a86d275ddb655c62e9ea80e76b189 to your computer and use it in GitHub Desktop.
Popover "Hints" in SwiftUI (Inspired by TipKit)
/*
HintKit.swift
INTRODUCTION
This is an implementation of popover "Hints" inspired by Apple's TipKit framework. However, it is designed to remove
the "magic" and make the presentation of hints explicit via an `isPresented` binding. This explicit binding is useful
for displaying hints in sequence, showing hints immediately in response to user actions, or adding custom constraints
to control when a hint should be displayed.
NOTES
1. Only one popover can be presented at a time. If you try to present more than one popover simultaneously, no popovers
will display (at least on my machine—this is likely undefined behavior).
2. An artificial delay needs to be added between dismissing one popover and displaying the next. If anyone knows a better
way to handle this, please let me know!
See: https://www.reddit.com/r/SwiftUI/comments/1cpie6h/how_to_correctly_display_a_sequence_of_popovers/
3. According to Apple documentation, iOS ignores the `arrowEdge` parameter in popover presentations.
See: https://developer.apple.com/documentation/swiftui/view/popover(ispresented:attachmentanchor:arrowedge:content:)
Enjoy!
*/
import SwiftUI
protocol Hint {
var title: Text { get }
var message: Text? { get }
var image: Image? { get }
}
struct PopoverHintViewModifier: ViewModifier {
@Binding var isPresented: Bool
let hint: Hint
let attachmentAnchor: PopoverAttachmentAnchor
let arrowEdge: Edge
func body(content: Content) -> some View {
content
.popover(
isPresented: $isPresented,
attachmentAnchor: attachmentAnchor,
arrowEdge: arrowEdge
) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
hint.image?
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 4) {
hint.title
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
hint.message?
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
isPresented = false
}) {
Image(systemName: "xmark")
.fontWeight(.bold)
.foregroundColor(.gray.opacity(0.6))
}
.accessibilityLabel("Close hint")
}
}
.frame(width: 280)
.padding(16)
.presentationCompactAdaptation(.popover)
}
}
}
extension View {
func popoverHint(
isPresented: Binding<Bool>,
hint: Hint,
attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds),
arrowEdge: Edge = .top
) -> some View {
self.modifier(
PopoverHintViewModifier(
isPresented: isPresented,
hint: hint,
attachmentAnchor: attachmentAnchor,
arrowEdge: arrowEdge
)
)
}
}
struct FavoriteLandmarkHint: Hint {
var title: Text {
Text("Save as a Favorite")
}
var message: Text? {
Text("Your favorite landmarks always appear at the top of the list.")
}
var image: Image? {
Image(systemName: "star")
}
}
struct ExploreLandmarkHint: Hint {
var title: Text {
Text("Explore Landmark")
}
var message: Text? {
Text("Explore landmarks around the world and learn more about them.")
}
var image: Image? {
Image(systemName: "map")
}
}
struct HintPreview: View {
@State private var firstHintIsPresented = true
@State private var secondHintIsPresented = false
var firstHint = FavoriteLandmarkHint()
var secondHint = ExploreLandmarkHint()
var body: some View {
HStack(spacing: 40) {
Image(systemName: "wand.and.stars")
.imageScale(.large)
.popoverHint(isPresented: $firstHintIsPresented, hint: firstHint)
.onChange(of: firstHintIsPresented) { wasPresented, isPresented in
if wasPresented {
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000)
secondHintIsPresented = true
}
}
}
Image(systemName: "map")
.imageScale(.large)
.popoverHint(isPresented: $secondHintIsPresented, hint: secondHint)
}
.padding()
}
}
#Preview {
HintPreview()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment