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

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand Down Expand Up @@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
45 changes: 45 additions & 0 deletions integration/tests/expo-native/flows/user-profile-custom-pages.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand Down Expand Up @@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
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
import expo.modules.kotlin.modules.Module
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 val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All @@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

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

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

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

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

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading