From eb5fb63c5add5e484accd19db75653713d401ca6 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 Jul 2026 10:29:32 -0600 Subject: [PATCH 1/2] feat(expo): add Android bottom sheet presentation --- .changeset/soft-diamonds-slide.md | 5 ++ .../expo/modules/clerk/ClerkAuthViewModule.kt | 56 ++++++++++++++++++- packages/expo/src/native/AuthView.tsx | 4 +- packages/expo/src/native/AuthView.types.ts | 14 +++++ .../src/native/__tests__/AuthView.test.tsx | 13 +++++ packages/expo/src/native/index.ts | 2 +- .../src/specs/NativeClerkAuthView.android.ts | 1 + .../expo/src/specs/NativeClerkAuthView.ts | 1 + 8 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .changeset/soft-diamonds-slide.md diff --git a/.changeset/soft-diamonds-slide.md b/.changeset/soft-diamonds-slide.md new file mode 100644 index 00000000000..15de558fe5f --- /dev/null +++ b/.changeset/soft-diamonds-slide.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add an Android `presentation` option to `AuthView`, including a native Material bottom sheet mode. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index e80dbe14590..382cb779729 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -2,9 +2,19 @@ package expo.modules.clerk import android.content.Context import android.util.Log +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner @@ -30,6 +40,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo var isDismissible: Boolean = true var logoMaxHeight: Float? = null var mode: String? = null + var presentation: String = "inline" private val onAuthEvent by EventDispatcher() @@ -61,9 +72,37 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo } @Composable + @OptIn(ExperimentalMaterial3Api::class) override fun Content() { - debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, activity: $activity") + debugLog( + TAG, + "setupView - mode: $mode, presentation: $presentation, isDismissible: $isDismissible, activity: $activity", + ) + + if (presentation != "bottomSheet") { + ClerkAuthView() + return + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + ModalBottomSheet( + onDismissRequest = ::sendDismissEvent, + sheetState = sheetState, + containerColor = clerkBackgroundColor(), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val authHeight = + if (sheetState.currentValue == SheetValue.Expanded) maxHeight else maxHeight / 2 + + Box(modifier = Modifier.fillMaxWidth().height(authHeight)) { + ClerkAuthView() + } + } + } + } + @Composable + private fun ClerkAuthView() { AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = authTheme(), @@ -76,6 +115,17 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo ) } + @Composable + private fun clerkBackgroundColor(): Color { + val isDarkMode = isSystemInDarkTheme() + val customTheme = Clerk.customTheme + val modeColors = if (isDarkMode) customTheme?.darkColors else customTheme?.lightColors + + return modeColors?.background + ?: customTheme?.colors?.background + ?: if (isDarkMode) Color(0xFF131316) else Color.White + } + private fun authTheme(): ClerkTheme? { val maxHeight = logoMaxHeight ?: return Clerk.customTheme val theme = Clerk.customTheme ?: ClerkTheme() @@ -119,6 +169,10 @@ class ClerkAuthViewModule : Module() { view.logoMaxHeight = logoMaxHeight } + Prop("presentation") { view: ClerkAuthNativeView, presentation: String? -> + view.presentation = presentation ?: "inline" + } + OnViewDidUpdateProps { view: ClerkAuthNativeView -> view.setupView() } diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index abe933ba51c..75abb1969c3 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -1,6 +1,6 @@ import { type ReactElement, useCallback } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; -import { Text, View } from 'react-native'; +import { Platform, Text, View } from 'react-native'; import NativeClerkAuthView from '../specs/NativeClerkAuthView'; import { isNativeSupported } from '../utils/native-module'; @@ -39,6 +39,7 @@ type AuthNativeEvent = NativeSyntheticEvent>; */ export function AuthView({ mode = 'signInOrUp', + presentation = 'inline', isDismissible = true, logoMaxHeight, onDismiss, @@ -68,6 +69,7 @@ export function AuthView({ { vi.mock('react-native', () => { return { + Platform: { OS: 'android' }, Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children), View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children), }; @@ -40,6 +41,18 @@ describe('AuthView', () => { expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ logoMaxHeight: 64 }); }); + test('uses inline presentation by default', () => { + render(); + + expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ presentation: 'inline' }); + }); + + test('passes bottom sheet presentation to the native auth view', () => { + render(); + + expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ presentation: 'bottomSheet' }); + }); + test('calls onDismiss when the native auth view emits dismissed', () => { const onDismiss = vi.fn(); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index b59a8eeb106..fb6ec38ec29 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -29,7 +29,7 @@ */ export { AuthView } from './AuthView'; -export type { AuthViewProps, AuthViewMode } from './AuthView.types'; +export type { AuthViewProps, AuthViewMode, AuthViewPresentation } from './AuthView.types'; export { UserButton } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts index 3d1ea374baa..e9903bdb501 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.android.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts @@ -7,6 +7,7 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + presentation?: string; onAuthEvent?: (event: NativeSyntheticEvent) => void; } diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts index 32dc196c4b2..7991a57ed4f 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -8,6 +8,7 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + presentation?: string; onAuthEvent?: (event: NativeSyntheticEvent) => void; } From 61e566372f54b2f3d0678872a8addefdfd529d97 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 Jul 2026 13:14:45 -0600 Subject: [PATCH 2/2] fix(expo): render auth content in partial sheet --- .../expo/modules/clerk/ClerkAuthViewModule.kt | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 382cb779729..ac58da45ad2 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -2,19 +2,30 @@ package expo.modules.clerk import android.content.Context import android.util.Log +import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner @@ -89,25 +100,32 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo onDismissRequest = ::sendDismissEvent, sheetState = sheetState, containerColor = clerkBackgroundColor(), + dragHandle = { SheetDragHandle() }, ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val authHeight = - if (sheetState.currentValue == SheetValue.Expanded) maxHeight else maxHeight / 2 - - Box(modifier = Modifier.fillMaxWidth().height(authHeight)) { - ClerkAuthView() + val density = LocalDensity.current + val sheetOffsetPx = + runCatching { sheetState.requireOffset() } + .getOrDefault(with(density) { maxHeight.toPx() }) + val sheetOffset = with(density) { sheetOffsetPx.toDp() } + val authHeight = (maxHeight - sheetOffset).coerceAtLeast(0.dp) + + if (sheetState.currentValue != SheetValue.Hidden) { + Box(modifier = Modifier.fillMaxWidth().height(authHeight)) { + ClerkAuthView(showDismissAction = false) + } } } } } @Composable - private fun ClerkAuthView() { + private fun ClerkAuthView(showDismissAction: Boolean = isDismissible) { AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = authTheme(), mode = authMode(mode), - isDismissible = isDismissible, + isDismissible = showDismissAction, onDismiss = ::sendDismissEvent, onAuthComplete = { sendDismissEvent() @@ -115,6 +133,43 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo ) } + @Composable + @OptIn(ExperimentalMaterial3Api::class) + private fun SheetDragHandle() { + Box(modifier = Modifier.fillMaxWidth().height(48.dp)) { + BottomSheetDefaults.DragHandle(modifier = Modifier.align(Alignment.Center)) + if (isDismissible) { + IconButton( + onClick = ::sendDismissEvent, + modifier = + Modifier.align(Alignment.CenterEnd) + .padding(end = 12.dp) + .semantics { contentDescription = "Close" }, + ) { + val color = clerkForegroundColor() + Canvas(modifier = Modifier.size(24.dp)) { + val inset = 4.dp.toPx() + val strokeWidth = 2.dp.toPx() + drawLine( + color = color, + start = Offset(inset, inset), + end = Offset(size.width - inset, size.height - inset), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(size.width - inset, inset), + end = Offset(inset, size.height - inset), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } + } + } + } + @Composable private fun clerkBackgroundColor(): Color { val isDarkMode = isSystemInDarkTheme() @@ -126,6 +181,17 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo ?: if (isDarkMode) Color(0xFF131316) else Color.White } + @Composable + private fun clerkForegroundColor(): Color { + val isDarkMode = isSystemInDarkTheme() + val customTheme = Clerk.customTheme + val modeColors = if (isDarkMode) customTheme?.darkColors else customTheme?.lightColors + + return modeColors?.foreground + ?: customTheme?.colors?.foreground + ?: if (isDarkMode) Color.White else Color.Black + } + private fun authTheme(): ClerkTheme? { val maxHeight = logoMaxHeight ?: return Clerk.customTheme val theme = Clerk.customTheme ?: ClerkTheme()