Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions Sources/ClerkKitUI/Common/HostedNavigation.swift
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions Sources/ClerkKitUI/Components/Auth/AuthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -129,13 +130,15 @@ public struct AuthView: View {
dismissToolbarItem
}
#endif
.hostedNavigationBarHidden()
.navigationDestination(for: Destination.self) {
$0.view
#if os(iOS)
.toolbar {
dismissToolbarItem
}
#endif
.hostedNavigationBarHidden()
.authFooter(macOSDismissAction: showDismissButton ? { dismiss() } : nil)
.environment(navigation)
.environment(authState)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ public struct UserProfileView<Route: Hashable, Destination: View>: View {
profileContent(user: user)
.navigationDestination(for: Route.self) { route in
view(for: route)
.hostedNavigationBarHidden()
.environment(sheetNavigation)
.environment(codeLimiter)
.environment(
Expand Down Expand Up @@ -209,7 +210,10 @@ public struct UserProfileView<Route: Hashable, Destination: View>: 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 {
Expand Down Expand Up @@ -336,8 +340,10 @@ public struct UserProfileView<Route: Hashable, Destination: View>: View {
}
#endif
}
.hostedNavigationBarHidden()
.navigationDestination(for: UserProfileBuiltInDestination.self) { destination in
view(for: destination)
.hostedNavigationBarHidden()
.environment(sheetNavigation)
.environment(codeLimiter)
.environment(
Expand Down
63 changes: 63 additions & 0 deletions Tests/UI/HostedNavigationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}