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
12 changes: 12 additions & 0 deletions .changeset/hosted-navigation-native-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/expo': minor
---

Add an embedded navigation mode to the native `UserProfileView` and `AuthView` components so they can be pushed onto your app's own navigation stack without a double header.

- New optional `hideHeader` prop hides Clerk's built-in navigation header while keeping its internal navigation working.
- New optional `onNavigationChange` prop reports the component's internal navigation state (`{ depth, canGoBack }`) so your header can show the right back affordance.
- The components now expose a ref with `goBack()` and `popToRoot()` to drive Clerk's internal stack from your own back buttons and gestures.
- New `@clerk/expo/native/router` entry point ships prewired expo-router screens (`UserProfileScreen`, `AuthScreen`) that handle header back buttons, iOS gestures, Android hardware back, and automatic route dismissal for you. Requires `expo-router` (new optional peer dependency).

Existing usage is unaffected: all new props are optional, and the components render exactly as before unless `hideHeader` is set. Requires the corresponding clerk-ios and clerk-android SDK releases with hosted-navigation support.
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.clerk.api.Clerk
import com.clerk.ui.auth.AuthMode
import com.clerk.ui.auth.AuthView
import com.clerk.ui.navigation.ClerkHostedNavigation
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
Expand All @@ -26,8 +29,11 @@ private fun debugLog(tag: String, message: String) {
class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var isDismissible: Boolean = true
var mode: String? = null
var hideHeader: Boolean = false

private val onAuthEvent by EventDispatcher()
private val onNavigationChange by EventDispatcher()
private val hostedNavigation = ClerkHostedNavigation()

init {
// At cold start, ClerkExpoModule.configure() may run before React's
Expand All @@ -54,9 +60,26 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
viewModelStoreOwner.viewModelStore.clear()
}

fun goBack() {
hostedNavigation.pop()
}

fun popToRoot() {
hostedNavigation.popToRoot()
}

@Composable
override fun Content() {
debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, activity: $activity")
debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, hideHeader: $hideHeader, activity: $activity")

val hosted = if (hideHeader) hostedNavigation else null
if (hosted != null) {
LaunchedEffect(hosted) {
snapshotFlow { hosted.depth }.collect { depth ->
onNavigationChange(mapOf("depth" to depth, "canGoBack" to (depth > 0)))
}
}
}

AuthView(
modifier = Modifier.fillMaxSize(),
Expand All @@ -67,6 +90,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
onAuthComplete = {
sendDismissEvent()
},
hostedNavigation = hosted,
)
}

Expand All @@ -92,7 +116,7 @@ class ClerkAuthViewModule : Module() {
Name("ClerkAuthView")

View(ClerkAuthNativeView::class) {
Events("onAuthEvent")
Events("onAuthEvent", "onNavigationChange")

Prop("mode") { view: ClerkAuthNativeView, mode: String? ->
view.mode = mode
Expand All @@ -102,10 +126,21 @@ class ClerkAuthViewModule : Module() {
view.isDismissible = isDismissible
}

Prop("hideHeader") { view: ClerkAuthNativeView, hideHeader: Boolean ->
view.hideHeader = hideHeader
}

AsyncFunction("goBack") { view: ClerkAuthNativeView ->
view.goBack()
}

AsyncFunction("popToRoot") { view: ClerkAuthNativeView ->
view.popToRoot()
}

OnViewDidUpdateProps { view: ClerkAuthNativeView ->
view.setupView()
}

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package expo.modules.clerk

import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.clerk.api.Clerk
import com.clerk.ui.navigation.ClerkHostedNavigation
import com.clerk.ui.userprofile.UserProfileView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
Expand All @@ -25,7 +26,10 @@ private fun debugLog(tag: String, message: String) {
class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
// clerk-android UserProfileView dismissibility is controlled by its onDismiss callback.
var isDismissible: Boolean = true
var hideHeader: Boolean = false
private val onProfileEvent by EventDispatcher()
private val onNavigationChange by EventDispatcher()
private val hostedNavigation = ClerkHostedNavigation()

private val viewModelStoreOwner = object : ViewModelStoreOwner {
private val store = ViewModelStore()
Expand All @@ -38,17 +42,35 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle
viewModelStoreOwner.viewModelStore.clear()
}

fun goBack() {
hostedNavigation.pop()
}

fun popToRoot() {
hostedNavigation.popToRoot()
}

@Composable
override fun Content() {
debugLog(TAG, "setupView - isDismissible: $isDismissible")
debugLog(TAG, "setupView - isDismissible: $isDismissible, hideHeader: $hideHeader")

val hosted = if (hideHeader) hostedNavigation else null
if (hosted != null) {
LaunchedEffect(hosted) {
snapshotFlow { hosted.depth }.collect { depth ->
onNavigationChange(mapOf("depth" to depth, "canGoBack" to (depth > 0)))
}
}
}

UserProfileView(
clerkTheme = Clerk.customTheme,
isDismissible = isDismissible,
onDismiss = {
debugLog(TAG, "Profile dismissed")
sendEvent("dismissed")
}
},
hostedNavigation = hosted,
)
}

Expand All @@ -62,12 +84,24 @@ class ClerkUserProfileViewModule : Module() {
Name("ClerkUserProfileView")

View(ClerkUserProfileNativeView::class) {
Events("onProfileEvent")
Events("onProfileEvent", "onNavigationChange")

Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
view.isDismissible = isDismissible
}

Prop("hideHeader") { view: ClerkUserProfileNativeView, hideHeader: Boolean ->
view.hideHeader = hideHeader
}

AsyncFunction("goBack") { view: ClerkUserProfileNativeView ->
view.goBack()
}

AsyncFunction("popToRoot") { view: ClerkUserProfileNativeView ->
view.popToRoot()
}

OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
view.setupView()
}
Expand Down
45 changes: 44 additions & 1 deletion packages/expo/ios/ClerkAuthNativeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import UIKit
public class ClerkAuthNativeView: ClerkNativeViewHost {
private var currentMode: String = "signInOrUp"
private var currentDismissible: Bool = true
private var currentHideHeader: Bool = false
private var didSendDismiss = false
private var hostedNavigation: ClerkExpoHostedAuthNavigation?

let onAuthEvent = EventDispatcher()
let onNavigationChange = EventDispatcher()

func setMode(_ mode: String?) {
let newMode = mode ?? "signInOrUp"
Expand All @@ -22,6 +25,21 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
setNeedsHostedViewUpdate()
}

func setHideHeader(_ hideHeader: Bool?) {
let newHideHeader = hideHeader ?? false
guard newHideHeader != currentHideHeader else { return }
currentHideHeader = newHideHeader
setNeedsHostedViewUpdate()
}

func goBack() {
hostedNavigation?.goBack()
}

func popToRoot() {
hostedNavigation?.popToRoot()
}

private func sendAuthEvent(type: ClerkNativeViewEvent) {
onAuthEvent(["type": type.rawValue])
}
Expand All @@ -42,9 +60,22 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
}

override func makeHostedController() -> UIViewController? {
let hosted: ClerkExpoHostedAuthNavigation?
if currentHideHeader {
let navigation = ClerkExpoHostedAuthNavigation()
navigation.onDepthChange = { [weak self] depth in
self?.onNavigationChange(["depth": depth, "canGoBack": depth > 0])
}
hosted = navigation
} else {
hosted = nil
}
hostedNavigation = hosted

return ClerkNativeBridge.shared.makeAuthViewController(
mode: currentMode,
dismissible: currentDismissible,
hostedNavigation: hosted,
onEvent: { [weak self] event, _ in
if event == .dismissed {
self?.sendDismissIfNeeded()
Expand All @@ -59,7 +90,7 @@ public class ClerkAuthViewModule: Module {
Name("ClerkAuthView")

View(ClerkAuthNativeView.self) {
Events("onAuthEvent")
Events("onAuthEvent", "onNavigationChange")

Prop("mode") { (view: ClerkAuthNativeView, mode: String?) in
view.setMode(mode)
Expand All @@ -68,6 +99,18 @@ public class ClerkAuthViewModule: Module {
Prop("isDismissible") { (view: ClerkAuthNativeView, isDismissible: Bool?) in
view.setDismissible(isDismissible)
}

Prop("hideHeader") { (view: ClerkAuthNativeView, hideHeader: Bool?) in
view.setHideHeader(hideHeader)
}

AsyncFunction("goBack") { (view: ClerkAuthNativeView) in
view.goBack()
}

AsyncFunction("popToRoot") { (view: ClerkAuthNativeView) in
view.popToRoot()
}
}
}
}
Loading
Loading