Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Preserve native user profile custom pages across tab switches and display their labels as native navigation titles on iOS.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': minor
---

Add push-only `customDestinations` to the native `UserProfileView` and `UserButton`, allowing custom profile pages to navigate forward without adding another row to the profile root.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Recomposer
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
Expand All @@ -22,13 +23,23 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
abstract class ClerkComposeNativeViewHost(
context: Context,
appContext: AppContext,
private val retainCompositionOnDetach: Boolean = false,
) : ExpoView(context, appContext) {
protected val activity: ComponentActivity? = findActivity(context)

private var recomposer: Recomposer? = null
private var recomposerJob: Job? = null

private val composeView = ComposeView(context).also { view ->
if (retainCompositionOnDetach) {
// Native tab navigators detach inactive screens from the window. Keep the
// composition alive until React actually destroys this Expo view so Clerk's
// internal navigation back stack survives a tab switch.
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
activity?.let { act ->
view.setViewTreeLifecycleOwner(act)
view.setViewTreeViewModelStoreOwner(act)
Expand All @@ -48,14 +59,19 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte
}

override fun onDetachedFromWindow() {
recomposer?.cancel()
recomposerJob?.cancel()
recomposer = null
recomposerJob = null
if (!retainCompositionOnDetach) {
stopRecomposer()
}
onHostDetachedFromWindow()
super.onDetachedFromWindow()
}

fun destroyHost() {
composeView.disposeComposition()
stopRecomposer()
onHostDestroyed()
}

private fun startRecomposer() {
if (activity == null || recomposerJob?.isActive == true) return

Expand All @@ -69,6 +85,13 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte
}
}

private fun stopRecomposer() {
recomposer?.cancel()
recomposerJob?.cancel()
recomposer = null
recomposerJob = null
}

fun setupView() {
startRecomposer()
composeView.setContent {
Expand All @@ -92,6 +115,8 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected open fun onHostDestroyed() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
Expand All @@ -22,17 +23,24 @@ import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()
private val customPageState =
ClerkUserProfileCustomPageState(resetCoveredPathsWhenInactive = true) { type, path ->
onCustomPageEvent(mapOf("type" to type, "path" to path))
}

init {
activity?.let { Clerk.attachActivity(it) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand Down Expand Up @@ -63,26 +71,41 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler

fun customPageCount(): Int = customPageViews.size

fun setCustomPages(customPages: String) {
if (customPagesJson == customPages) return
val validPaths = runCatching { userProfileCustomPagePaths(customPages) }.getOrDefault(emptySet())
customPageState.reconcileCustomPagePaths(validPaths)
customPagesJson = customPages
}

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
customPageState.navigate(action, routeKey)
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
val customNavigator = LocalUserProfileCustomNavigator.current
val pages = customPages()
val view = customPageViews.getOrNull(pages.indexOfFirst { it.routeKey == routeKey })

LaunchedEffect(routeKey, customNavigator, view) {
customPageState.configureNavigation(
navigateBack = customNavigator::navigateBack,
popToRoot = customNavigator::popToRoot,
push = customNavigator::push,
)
if (view == null) {
customNavigator.popToRoot()
return@LaunchedEffect
}
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
customPageState.pageDidPresent(routeKey)
}

if (view == null) return

DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
onDispose { customPageState.pageDidDismiss(routeKey) }
}

AndroidView(
Expand All @@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = customPages().filter { it.showAsRow }.map { it.nativeRow }

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
private fun customPages(): List<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All @@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package expo.modules.clerk

import android.os.Handler
import android.os.Looper

private fun postToMainThread(action: () -> Unit) {
Handler(Looper.getMainLooper()).post { action() }
}

internal class ClerkUserProfileCustomPageState(
private val resetCoveredPathsWhenInactive: Boolean = false,
private val postInactiveReset: ((() -> Unit) -> Unit) = ::postToMainThread,
private val pageEventHandler: (type: String, path: String) -> Unit,
) {
private val retainedPaths = mutableListOf<String>()
private var navigateBackAction: (() -> Unit)? = null
private var popToRootAction: (() -> Unit)? = null
private var pushAction: ((String) -> Unit)? = null
private var hasObservedUserId = false
private var observedUserId: String? = null
private var navigationTransitionGeneration = 0

fun configureNavigation(
navigateBack: () -> Unit,
popToRoot: () -> Unit,
push: (String) -> Unit,
) {
navigateBackAction = navigateBack
popToRootAction = popToRoot
pushAction = push
}

fun pageDidPresent(path: String) {
cancelPendingInactiveReset()
if (path !in retainedPaths) {
retainedPaths.add(path)
}
pageEventHandler("presented", path)
}

fun pageDidDismiss(path: String) {
val pathIndex = retainedPaths.lastIndexOf(path)
if (pathIndex == -1) return

// Navigation 3 disposes a destination when another page covers it. Keep that
// earlier path until it is actually removed from the native back stack.
if (pathIndex == retainedPaths.lastIndex) {
retainedPaths.removeAt(pathIndex)
if (resetCoveredPathsWhenInactive) {
scheduleInactiveReset()
}
}
pageEventHandler("dismissed", path)
}

fun userDidChange(userId: String?) {
if (!hasObservedUserId) {
observedUserId = userId
hasObservedUserId = true
return
}

if (observedUserId == userId) return
observedUserId = userId
invalidateNavigation()
}

fun reconcileCustomPagePaths(validPaths: Set<String>) {
if (retainedPaths.all(validPaths::contains)) return
invalidateNavigation()
}

fun navigate(action: String, routeKey: String?) {
when (action) {
"back" -> navigateBackAction?.invoke()
"popToRoot" -> invalidateNavigation()
"push" -> {
val path = routeKey ?: return
val push = pushAction ?: return
if (path in retainedPaths) return
retainedPaths.add(path)
push(path)
}
}
}

internal fun retainedPathsForTesting(): List<String> = retainedPaths.toList()

private fun invalidateNavigation() {
cancelPendingInactiveReset()
if (retainedPaths.isEmpty()) return

val dismissedPaths = retainedPaths.asReversed().distinct()
retainedPaths.clear()
popToRootAction?.invoke()
dismissedPaths.forEach { pageEventHandler("dismissed", it) }
}

private fun scheduleInactiveReset() {
val generation = ++navigationTransitionGeneration
postInactiveReset {
if (generation == navigationTransitionGeneration) {
resetInactivePaths()
}
}
}

private fun cancelPendingInactiveReset() {
navigationTransitionGeneration += 1
}

private fun resetInactivePaths() {
navigateBackAction = null
popToRootAction = null
pushAction = null
val dismissedPaths = retainedPaths.asReversed().toList()
retainedPaths.clear()
dismissedPaths.forEach { pageEventHandler("dismissed", it) }
}
}
Loading
Loading