Last active
December 12, 2025 09:24
-
-
Save gerdemb/045a86d275ddb655c62e9ea80e76b189 to your computer and use it in GitHub Desktop.
Popover "Hints" in SwiftUI (Inspired by TipKit)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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