Skip to content

Commit f6e6de0

Browse files
authored
Sean/support custom user profile rows (#382)
* Add external navigation path support to UserProfileView Allow embedding UserProfileView in a parent NavigationStack by passing an optional navigationPath binding. When provided, destinations push onto the parent's path instead of an internal stack. - Add externalPath and externalPathBaseCount to UserProfileNavigation - Add navigate(to:) and popToRoot() methods for unified navigation - Extract profileContent to conditionally wrap in NavigationStack - Track isEmbedded in telemetry payload # Conflicts: # Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift * Refactor UserProfileView to configure external navigation path lazily Move external navigation path configuration from init to taskOnce to ensure the binding is properly captured when the view appears. This fixes an issue where the external path could be stale when passed through initializer state wrappers. * Add includingSelf parameter to popToRoot for account deletion When deleting the last session in embedded mode, we need to also pop the UserProfileView entry itself from the parent's navigation stack, not just the views pushed by the profile flow. * Fix bounds checking in popToRoot to prevent negative values * Refactor UserProfileView navigation to use router pattern Replace imperative navigation.navigate() calls with a new UserProfileRouter struct that manages push/pop operations via closures. This provides a cleaner API for embedded and standalone navigation scenarios. - Extract routing logic from UserProfileNavigation into UserProfileRouter - Track embedded push count via @State to properly pop to root - Inject router through environment for consistent access * Refactor UserProfileView to use @entry macro for router Move navigation path from UserProfileNavigation to local state within UserProfileView, simplifying the navigation class to only manage presentation state. Replace custom EnvironmentKey with @entry macro for cleaner environment value declaration. * Rename UserProfileNavigation to UserProfileSheetNavigation * Simplify popToRoot by moving includingSelf logic inside * Simplify popToRoot by combining removeLast operations * Replace push count tracking with initial path count Use initialPathCount captured at init to calculate entries to remove during popToRoot, eliminating the need for onChange tracking and embeddedPushCount state management. * Add includingSelf parameter to UserProfileRouter.popToRoot Clarifies the caller's intent when popping to root after account deletion. The decision about whether to include the current view in the pop is now made explicitly at the call site based on whether the user still exists and if the account switcher will be presented. * Add preview for UserProfileView embedded in parent NavigationStack * Add custom rows support to UserProfileView Allow developers to inject custom rows into the root profile screen with flexible placement options. Custom rows can be positioned at section start/end or before/after specific built-in rows. * Type user profile custom navigation routes Replace AnyHashable-based custom row and destination routing with typed Route generics, add UserProfileNavigator for environment-driven navigation, and keep a built-in router for Clerk-owned child views. * Add custom user profile rows to UserButton Support custom profile rows and destination routing from `UserButton` into `UserProfileView`. Update profile row icon rendering so asset and system icons share consistent sizing and theming. * Remove built-in row navigation from UserProfileNavigator The public navigator should only allow pushing custom routes. Built-in row navigation is now handled internally through the private navigate(to:) helper method. * Simplify UserProfileView navigation by inlining navigator The UserProfileNavigator instance was being created on every body evaluation via the computed property. Inline the calls to navigate() directly and construct the navigator only when needed for child views. * Rename popToRoot to dismiss with explicit action enum * Rename UserProfileCustomRow to UserProfileCustomItem Use view modifier API for custom items and destinations * Move initialPathCount initialization to onFirstAppear This ensures the initial path count is captured when the view actually appears rather than during init, providing more reliable navigation state tracking. * Move UserButtonPresentationContext below public struct * Inline UserProfileView sheet content in UserButton * Improve row ID to support duplicate routes * Add missing environment objects to SwiftUI previews * Refactor UserProfileView navigation and extract header Separate built-in and custom route navigation paths to allow custom routes to use a distinct navigation destination. Extract header view and custom row helpers into separate files for better organization. * Remove unused bundle parameter from UserProfileCustomRow SwiftUI's LocalizedStringKey automatically uses the calling module's bundle via an internal init. Explicit bundle passing was redundant and added unnecessary API surface. * Change internal properties from public to internal * Add configurable width and height to asset icon type
1 parent be40260 commit f6e6de0

15 files changed

Lines changed: 918 additions & 192 deletions

Sources/ClerkKitUI/Components/UserButton/UserButton.swift

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,14 @@ import SwiftUI
5151
/// }
5252
/// }
5353
/// ```
54-
public struct UserButton<SignedOutContent: View>: View {
54+
public struct UserButton<Route: Hashable, SignedOutContent: View, Destination: View>: View {
5555
@Environment(Clerk.self) private var clerk
5656
@Environment(\.clerkTheme) private var theme
5757

58-
enum PresentationContext {
59-
case standard
60-
case sessionTaskToolbar
61-
}
62-
6358
@State private var presentedSheet: PresentedSheet?
64-
private let presentationContext: PresentationContext
59+
private let presentationContext: UserButtonPresentationContext
60+
private let customRows: [UserProfileCustomRow<Route>]
61+
private let customDestination: (@MainActor (Route) -> Destination)?
6562
private let signedOutContent: () -> SignedOutContent
6663

6764
private enum PresentedSheet: String, Identifiable {
@@ -80,29 +77,39 @@ public struct UserButton<SignedOutContent: View>: View {
8077
/// and handle presenting the user profile sheet when tapped.
8178
public init(
8279
@ViewBuilder signedOutContent: @escaping () -> SignedOutContent
83-
) {
80+
) where Route == Never, Destination == EmptyView {
8481
self.init(
8582
presentationContext: .standard,
83+
customRows: [],
84+
customDestination: nil,
8685
signedOutContent: signedOutContent
8786
)
8887
}
8988

9089
init(
91-
presentationContext: PresentationContext,
90+
presentationContext: UserButtonPresentationContext,
91+
customRows: [UserProfileCustomRow<Route>],
92+
customDestination: (@MainActor (Route) -> Destination)?,
9293
@ViewBuilder signedOutContent: @escaping () -> SignedOutContent
9394
) {
9495
self.presentationContext = presentationContext
96+
self.customRows = customRows
97+
self.customDestination = customDestination
9598
self.signedOutContent = signedOutContent
9699
}
97100

98101
/// Creates a new user button with no signed-out content.
99-
public init() where SignedOutContent == EmptyView {
102+
public init() where Route == Never, SignedOutContent == EmptyView, Destination == EmptyView {
100103
self.init(presentationContext: .standard)
101104
}
102105

103-
init(presentationContext: PresentationContext) where SignedOutContent == EmptyView {
106+
init(
107+
presentationContext: UserButtonPresentationContext
108+
) where Route == Never, SignedOutContent == EmptyView, Destination == EmptyView {
104109
self.init(
105110
presentationContext: presentationContext,
111+
customRows: [],
112+
customDestination: nil,
106113
signedOutContent: { EmptyView() }
107114
)
108115
}
@@ -142,8 +149,13 @@ public struct UserButton<SignedOutContent: View>: View {
142149
.sheet(item: $presentedSheet) { sheet in
143150
switch sheet {
144151
case .userProfile:
145-
UserProfileView()
146-
.presentationDragIndicator(.visible)
152+
UserProfileView(
153+
isDismissable: true,
154+
navigationPath: nil,
155+
customRows: customRows,
156+
customDestination: customDestination
157+
)
158+
.presentationDragIndicator(.visible)
147159
case .sessionTaskAuth:
148160
AuthView()
149161
.presentationDragIndicator(.visible)
@@ -163,7 +175,24 @@ public struct UserButton<SignedOutContent: View>: View {
163175
}
164176
}
165177

178+
enum UserButtonPresentationContext {
179+
case standard
180+
case sessionTaskToolbar
181+
}
182+
166183
extension UserButton {
184+
/// Replaces the custom rows shown in the presented user profile.
185+
public func userProfileRows(
186+
_ rows: [UserProfileCustomRow<Route>]
187+
) -> UserButton<Route, SignedOutContent, Destination> {
188+
UserButton<Route, SignedOutContent, Destination>(
189+
presentationContext: presentationContext,
190+
customRows: rows,
191+
customDestination: customDestination,
192+
signedOutContent: signedOutContent
193+
)
194+
}
195+
167196
private func handleTap() {
168197
switch presentationContext {
169198
case .sessionTaskToolbar:
@@ -178,6 +207,47 @@ extension UserButton {
178207
}
179208
}
180209

210+
extension UserButton where Destination == EmptyView {
211+
/// Sets the custom destination builder used by custom rows in the presented user profile.
212+
public func userProfileDestination<NewDestination: View>(
213+
@ViewBuilder _ destination: @escaping @MainActor (Route) -> NewDestination
214+
) -> UserButton<Route, SignedOutContent, NewDestination> {
215+
UserButton<Route, SignedOutContent, NewDestination>(
216+
presentationContext: presentationContext,
217+
customRows: customRows,
218+
customDestination: destination,
219+
signedOutContent: signedOutContent
220+
)
221+
}
222+
}
223+
224+
extension UserButton where Route == Never, Destination == EmptyView {
225+
/// Sets the custom rows shown in the presented user profile.
226+
public func userProfileRows<NewRoute: Hashable>(
227+
_ rows: [UserProfileCustomRow<NewRoute>]
228+
) -> UserButton<NewRoute, SignedOutContent, EmptyView> {
229+
UserButton<NewRoute, SignedOutContent, EmptyView>(
230+
presentationContext: presentationContext,
231+
customRows: rows,
232+
customDestination: nil,
233+
signedOutContent: signedOutContent
234+
)
235+
}
236+
237+
/// Sets the custom destination builder used by custom rows in the presented user profile.
238+
public func userProfileDestination<NewRoute: Hashable, NewDestination: View>(
239+
for _: NewRoute.Type = NewRoute.self,
240+
@ViewBuilder _ destination: @escaping @MainActor (NewRoute) -> NewDestination
241+
) -> UserButton<NewRoute, SignedOutContent, NewDestination> {
242+
UserButton<NewRoute, SignedOutContent, NewDestination>(
243+
presentationContext: presentationContext,
244+
customRows: [],
245+
customDestination: destination,
246+
signedOutContent: signedOutContent
247+
)
248+
}
249+
}
250+
181251
#Preview {
182252
UserButton()
183253
.clerkPreview()

Sources/ClerkKitUI/Components/UserButton/UserButtonAccountSwitcher.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import SwiftUI
1111
struct UserButtonAccountSwitcher: View {
1212
@Environment(Clerk.self) private var clerk
1313
@Environment(\.clerkTheme) private var theme
14-
@Environment(UserProfileNavigation.self) private var navigation
14+
@Environment(UserProfileSheetNavigation.self) private var navigation
1515
@Environment(\.dismiss) private var dismiss
1616

1717
@Binding private var contentHeight: CGFloat
@@ -171,6 +171,7 @@ struct UserButtonAccountSwitcher: View {
171171
#Preview {
172172
UserButtonAccountSwitcher()
173173
.clerkPreview()
174+
.environment(UserProfileSheetNavigation())
174175
.environment(\.clerkTheme, .clerk)
175176
}
176177

Sources/ClerkKitUI/Components/UserButton/UserProfileRowView.swift

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,24 @@ import SwiftUI
1010
struct UserProfileRowView: View {
1111
@Environment(\.clerkTheme) private var theme
1212

13-
let icon: String
13+
let icon: UserProfileRowIcon
1414
let text: LocalizedStringKey
15+
let bundle: Bundle?
16+
17+
init(icon: UserProfileRowIcon, text: LocalizedStringKey, bundle: Bundle? = .module) {
18+
self.icon = icon
19+
self.text = text
20+
self.bundle = bundle
21+
}
22+
23+
init(icon: String, text: LocalizedStringKey, bundle: Bundle? = .module) {
24+
self.init(icon: .asset(name: icon), text: text, bundle: bundle)
25+
}
1526

1627
var body: some View {
1728
HStack(spacing: 16) {
18-
Image(icon, bundle: .module)
19-
.resizable()
20-
.scaledToFit()
21-
.frame(width: 48, height: 24)
22-
.foregroundStyle(theme.colors.mutedForeground)
23-
Text(text, bundle: .module)
29+
iconView
30+
Text(text, bundle: bundle)
2431
.font(theme.fonts.body)
2532
.fontWeight(.semibold)
2633
.foregroundStyle(theme.colors.foreground)
@@ -31,6 +38,27 @@ struct UserProfileRowView: View {
3138
.padding(.horizontal, 24)
3239
.contentShape(.rect)
3340
}
41+
42+
@ViewBuilder
43+
private var iconView: some View {
44+
switch icon {
45+
case .asset(let name, let width, let height):
46+
Image(name, bundle: bundle)
47+
.renderingMode(.template)
48+
.resizable()
49+
.scaledToFit()
50+
.frame(width: width, height: height)
51+
.frame(width: 48, height: 24)
52+
.foregroundStyle(theme.colors.mutedForeground)
53+
case .system(let name):
54+
Image(systemName: name)
55+
.resizable()
56+
.scaledToFit()
57+
.frame(maxWidth: 21, maxHeight: 21)
58+
.frame(width: 48, height: 24)
59+
.foregroundStyle(theme.colors.mutedForeground)
60+
}
61+
}
3462
}
3563

3664
#Preview {

Sources/ClerkKitUI/Components/UserProfile/BackupCodesView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import SwiftUI
1010

1111
struct BackupCodesView: View {
1212
@Environment(\.clerkTheme) private var theme
13-
@Environment(UserProfileNavigation.self) private var navigation
13+
@Environment(UserProfileSheetNavigation.self) private var navigation
1414
@Environment(\.dismiss) private var dismiss
1515

1616
enum MfaType {
@@ -132,6 +132,7 @@ struct BackupCodesGrid: View {
132132
backupCodes: ["abc", "def", "ghi", "jkl", "lmn", "opq", "rst", "uvw", "xyz"],
133133
mfaType: .authenticatorApp
134134
)
135+
.environment(UserProfileSheetNavigation())
135136
}
136137

137138
#endif

Sources/ClerkKitUI/Components/UserProfile/UserProfileAddMfaView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import SwiftUI
1111
struct UserProfileAddMfaView: View {
1212
@Environment(Clerk.self) private var clerk
1313
@Environment(\.clerkTheme) private var theme
14-
@Environment(UserProfileNavigation.self) private var navigation
14+
@Environment(UserProfileSheetNavigation.self) private var navigation
1515
@Environment(\.dismiss) private var dismiss
1616

1717
@State private var error: Error?

0 commit comments

Comments
 (0)