diff --git a/Sources/ClerkKitUI/Common/HostedNavigation.swift b/Sources/ClerkKitUI/Common/HostedNavigation.swift new file mode 100644 index 00000000..5b10fd0b --- /dev/null +++ b/Sources/ClerkKitUI/Common/HostedNavigation.swift @@ -0,0 +1,90 @@ +// +// HostedNavigation.swift +// Clerk +// + +#if os(iOS) || os(macOS) + +import SwiftUI + +/// Lets a host app that embeds Clerk components inside its own navigation chrome +/// (e.g. Clerk's Expo SDK) hide Clerk's navigation bars while observing and driving +/// the component's internal navigation stack. +/// +/// Setting an instance in the environment via `\.clerkHostedNavigation` hides the +/// navigation bars of `UserProfileView` and `AuthView` (iOS only). When the +/// environment value is `nil` (the default), component behavior is unchanged. +/// +/// `UserProfileView` hosts should prefer the public `navigationPath:` initializer and +/// own the stack directly — hiding is the only effect this environment value has on it. +/// `AuthView` owns an internal stack, so it additionally reports depth changes through +/// ``onDepthChange`` and executes ``pop()`` / ``popToRoot()``. +/// +/// Only one embedded component drives an instance at a time; the most recently +/// appeared component wins. +@_spi(FrameworkIntegration) +@MainActor +public final class ClerkHostedNavigation { + /// Called with the number of screens pushed above the embedded component's root + /// whenever its internal navigation stack changes. + public var onDepthChange: ((Int) -> Void)? + + private var popHandler: ((_ toRoot: Bool) -> Void)? + + public init() {} + + /// Pops one screen off the embedded component's internal stack. No-op at the root. + public func pop() { + popHandler?(false) + } + + /// Pops the embedded component's internal stack back to its root screen. + public func popToRoot() { + popHandler?(true) + } + + func register(popHandler: @escaping (_ toRoot: Bool) -> Void) { + self.popHandler = popHandler + } + + func unregister() { + popHandler = nil + } + + func reportDepth(_ depth: Int) { + onDepthChange?(depth) + } +} + +@_spi(FrameworkIntegration) +extension EnvironmentValues { + /// Hosted-navigation coordinator for embedding Clerk components headerless inside + /// a host-owned navigation UI. `nil` (the default) leaves Clerk's built-in + /// navigation chrome untouched. + @Entry public var clerkHostedNavigation: ClerkHostedNavigation? +} + +private struct HostedNavigationBarHiddenModifier: ViewModifier { + @Environment(\.clerkHostedNavigation) private var hostedNavigation + + func body(content: Content) -> some View { + #if os(iOS) + if hostedNavigation != nil { + content.toolbar(.hidden, for: .navigationBar) + } else { + content + } + #else + content + #endif + } +} + +extension View { + /// Hides the navigation bar when hosted navigation is active (iOS only). + func hostedNavigationBarHidden() -> some View { + modifier(HostedNavigationBarHiddenModifier()) + } +} + +#endif diff --git a/Sources/ClerkKitUI/Components/Auth/AuthView.swift b/Sources/ClerkKitUI/Components/Auth/AuthView.swift index e86707c2..305950ef 100644 --- a/Sources/ClerkKitUI/Components/Auth/AuthView.swift +++ b/Sources/ClerkKitUI/Components/Auth/AuthView.swift @@ -65,6 +65,7 @@ public struct AuthView: View { @Environment(Clerk.self) private var clerk @Environment(\.clerkTheme) private var theme @Environment(\.dismiss) private var dismiss + @Environment(\.clerkHostedNavigation) private var hostedNavigation /// Navigation state for the auth flow. @State private var navigation = AuthNavigation() @@ -129,6 +130,7 @@ public struct AuthView: View { dismissToolbarItem } #endif + .hostedNavigationBarHidden() .navigationDestination(for: Destination.self) { $0.view #if os(iOS) @@ -136,6 +138,7 @@ public struct AuthView: View { dismissToolbarItem } #endif + .hostedNavigationBarHidden() .authFooter(macOSDismissAction: showDismissButton ? { dismiss() } : nil) .environment(navigation) .environment(authState) @@ -162,6 +165,23 @@ public struct AuthView: View { if let callbackContinuation = clerk.callbackContinuation { resumeAuth(callbackContinuation) } + if let hostedNavigation { + hostedNavigation.register { [navigation] toRoot in + guard !navigation.path.isEmpty else { return } + if toRoot { + navigation.path = [] + } else { + navigation.path.removeLast() + } + } + hostedNavigation.reportDepth(navigation.path.count) + } + } + .onDisappear { + hostedNavigation?.unregister() + } + .onChange(of: navigation.path.count) { _, newCount in + hostedNavigation?.reportDepth(newCount) } .task { let checkpoint = authState.environmentRefreshCheckpoint(for: clerk) diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift index 874ac682..faca74c1 100644 --- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift +++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift @@ -163,6 +163,7 @@ public struct UserProfileView: View { profileContent(user: user) .navigationDestination(for: Route.self) { route in view(for: route) + .hostedNavigationBarHidden() .environment(sheetNavigation) .environment(codeLimiter) .environment( @@ -209,7 +210,10 @@ public struct UserProfileView: View { UserProfileUpdateProfileView(user: user) } .sheet(isPresented: $sheetNavigation.authViewIsPresented) { + // The add-account sheet is modal over the host, so it keeps Clerk's own + // navigation chrome even when this profile is hosted. AuthView() + .environment(\.clerkHostedNavigation, nil) } .task { for await event in clerk.auth.events { @@ -336,8 +340,10 @@ public struct UserProfileView: View { } #endif } + .hostedNavigationBarHidden() .navigationDestination(for: UserProfileBuiltInDestination.self) { destination in view(for: destination) + .hostedNavigationBarHidden() .environment(sheetNavigation) .environment(codeLimiter) .environment( diff --git a/Tests/UI/HostedNavigationTests.swift b/Tests/UI/HostedNavigationTests.swift new file mode 100644 index 00000000..90b41f8e --- /dev/null +++ b/Tests/UI/HostedNavigationTests.swift @@ -0,0 +1,63 @@ +@_spi(FrameworkIntegration) @testable import ClerkKitUI +import Testing + +@MainActor +struct HostedNavigationTests { + @Test + func reportDepthForwardsToOnDepthChange() { + let hostedNavigation = ClerkHostedNavigation() + var received: [Int] = [] + hostedNavigation.onDepthChange = { received.append($0) } + + hostedNavigation.reportDepth(1) + hostedNavigation.reportDepth(0) + + #expect(received == [1, 0]) + } + + @Test + func popRoutesToRegisteredHandler() { + let hostedNavigation = ClerkHostedNavigation() + var pops: [Bool] = [] + hostedNavigation.register { toRoot in pops.append(toRoot) } + + hostedNavigation.pop() + hostedNavigation.popToRoot() + + #expect(pops == [false, true]) + } + + @Test + func popIsNoOpWithoutRegisteredHandler() { + let hostedNavigation = ClerkHostedNavigation() + + hostedNavigation.pop() + hostedNavigation.popToRoot() + } + + @Test + func unregisterStopsRoutingPops() { + let hostedNavigation = ClerkHostedNavigation() + var pops: [Bool] = [] + hostedNavigation.register { toRoot in pops.append(toRoot) } + hostedNavigation.unregister() + + hostedNavigation.pop() + + #expect(pops.isEmpty) + } + + @Test + func lastRegisteredHandlerWins() { + let hostedNavigation = ClerkHostedNavigation() + var first = 0 + var second = 0 + hostedNavigation.register { _ in first += 1 } + hostedNavigation.register { _ in second += 1 } + + hostedNavigation.pop() + + #expect(first == 0) + #expect(second == 1) + } +}