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

Add an Android `presentation` option to `AuthView`, including a native Material bottom sheet mode.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +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
Expand All @@ -30,6 +51,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()

Expand Down Expand Up @@ -61,21 +83,115 @@ 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(),
dragHandle = { SheetDragHandle() },
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
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(showDismissAction: Boolean = isDismissible) {
AuthView(
modifier = Modifier.fillMaxSize(),
clerkTheme = authTheme(),
mode = authMode(mode),
isDismissible = isDismissible,
isDismissible = showDismissAction,
onDismiss = ::sendDismissEvent,
onAuthComplete = {
sendDismissEvent()
},
)
}

@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()
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
}

@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()
Expand Down Expand Up @@ -119,6 +235,10 @@ class ClerkAuthViewModule : Module() {
view.logoMaxHeight = logoMaxHeight
}

Prop("presentation") { view: ClerkAuthNativeView, presentation: String? ->
view.presentation = presentation ?: "inline"
}

OnViewDidUpdateProps { view: ClerkAuthNativeView ->
view.setupView()
}
Expand Down
4 changes: 3 additions & 1 deletion packages/expo/src/native/AuthView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,6 +39,7 @@ type AuthNativeEvent = NativeSyntheticEvent<Readonly<{ type: string }>>;
*/
export function AuthView({
mode = 'signInOrUp',
presentation = 'inline',
isDismissible = true,
logoMaxHeight,
onDismiss,
Expand Down Expand Up @@ -68,6 +69,7 @@ export function AuthView({
<NativeClerkAuthView
style={{ flex: 1 }}
mode={mode}
{...(Platform.OS === 'android' ? { presentation } : {})}
isDismissible={isDismissible}
logoMaxHeight={logoMaxHeight}
onAuthEvent={handleAuthEvent}
Expand Down
14 changes: 14 additions & 0 deletions packages/expo/src/native/AuthView.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*/
export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp';

/** Android presentation used by the native authentication view. */
export type AuthViewPresentation = 'inline' | 'bottomSheet';

/**
* Props for the AuthView component.
*
Expand All @@ -26,6 +29,17 @@ export interface AuthViewProps {
*/
mode?: AuthViewMode;

/**
* Android presentation for the native authentication view.
*
* `'inline'` preserves the standard behavior and fills the parent container.
* `'bottomSheet'` presents the same Clerk flow in a Material bottom sheet.
* This option is ignored on iOS, where the parent controls presentation.
*
* @default 'inline'
*/
presentation?: AuthViewPresentation;

/**
* Whether the authentication view can be dismissed by the user.
*
Expand Down
13 changes: 13 additions & 0 deletions packages/expo/src/native/__tests__/AuthView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('../../utils/native-module', () => {

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),
};
Expand All @@ -40,6 +41,18 @@ describe('AuthView', () => {
expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ logoMaxHeight: 64 });
});

test('uses inline presentation by default', () => {
render(<AuthView />);

expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ presentation: 'inline' });
});

test('passes bottom sheet presentation to the native auth view', () => {
render(<AuthView presentation='bottomSheet' />);

expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ presentation: 'bottomSheet' });
});

test('calls onDismiss when the native auth view emits dismissed', () => {
const onDismiss = vi.fn();

Expand Down
2 changes: 1 addition & 1 deletion packages/expo/src/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/expo/src/specs/NativeClerkAuthView.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
presentation?: string;
onAuthEvent?: (event: NativeSyntheticEvent<AuthEvent>) => void;
}

Expand Down
1 change: 1 addition & 0 deletions packages/expo/src/specs/NativeClerkAuthView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
presentation?: string;
onAuthEvent?: (event: NativeSyntheticEvent<AuthEvent>) => void;
}

Expand Down
Loading