diff --git a/.changeset/expo-native-custom-logo.md b/.changeset/expo-native-custom-logo.md new file mode 100644 index 00000000000..ab866391405 --- /dev/null +++ b/.changeset/expo-native-custom-logo.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Add a `logo` prop to the native `AuthView`, allowing Expo apps to replace the dashboard-configured logo with custom React Native content on Android and iOS. 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 7fa24fe48bf..c3a42b38181 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,10 +2,16 @@ package expo.modules.clerk import android.content.Context import android.util.Log +import android.view.View +import android.view.ViewGroup import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.clerk.api.Clerk @@ -30,6 +36,21 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo var isDismissible: Boolean = true var logoMaxHeight: Float? = null var mode: String? = null + var logoView: View? = null + private set + + private var logoWidth = 0 + private var logoHeight = 0 + private val logoLayoutListener = + View.OnLayoutChangeListener { view, left, top, right, bottom, _, _, _, _ -> + val width = right - left + val height = bottom - top + if (view === logoView && (width != logoWidth || height != logoHeight)) { + logoWidth = width + logoHeight = height + setupView() + } + } private val onAuthEvent by EventDispatcher() @@ -65,6 +86,9 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = authTheme(), + logo = logoView?.let { view -> + { ReactLogoView(view = view, width = logoWidth, height = logoHeight) } + }, mode = authMode(mode), isDismissible = isDismissible, onDismiss = ::sendDismissEvent, @@ -74,6 +98,25 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo ) } + fun setLogoView(view: View) { + if (logoView === view) return + logoView?.removeOnLayoutChangeListener(logoLayoutListener) + logoView = view + logoWidth = view.width + logoHeight = view.height + view.addOnLayoutChangeListener(logoLayoutListener) + setupView() + } + + fun removeLogoView(view: View) { + if (logoView !== view) return + view.removeOnLayoutChangeListener(logoLayoutListener) + logoView = null + logoWidth = 0 + logoHeight = 0 + setupView() + } + private fun authTheme(): ClerkTheme? { val maxHeight = logoMaxHeight ?: return Clerk.customTheme val theme = Clerk.customTheme ?: ClerkTheme() @@ -98,6 +141,25 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo } } +@Composable +private fun ReactLogoView(view: View, width: Int, height: Int) { + val density = LocalDensity.current + val modifier = + if (width > 0 && height > 0) { + with(density) { Modifier.size(width.toDp(), height.toDp()) } + } else { + Modifier.wrapContentSize() + } + + AndroidView( + modifier = modifier, + factory = { + (view.parent as? ViewGroup)?.removeView(view) + view + }, + ) +} + class ClerkAuthViewModule : Module() { override fun definition() = ModuleDefinition { Name("ClerkAuthView") @@ -105,6 +167,26 @@ class ClerkAuthViewModule : Module() { View(ClerkAuthNativeView::class) { Events("onAuthEvent") + GroupView { + AddChildView { parent, child, _ -> + parent.setLogoView(child) + } + GetChildCount { parent -> + if (parent.logoView == null) 0 else 1 + } + GetChildViewAt { parent, index -> + if (index == 0) parent.logoView else null + } + RemoveChildView { parent, child -> + parent.removeLogoView(child) + } + RemoveChildViewAt { parent, index -> + if (index == 0) { + parent.logoView?.let(parent::removeLogoView) + } + } + } + Prop("mode") { view: ClerkAuthNativeView, mode: String? -> view.mode = mode } diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index f0397622ada..6d3ac2f97cb 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -5,6 +5,8 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { private var currentMode: String = "signInOrUp" private var currentDismissible: Bool = true private var currentLogoMaxHeight: CGFloat? + private let logoState = ClerkInlineAuthLogoState() + private var logoBoundsObservation: NSKeyValueObservation? private var didSendDismiss = false let onAuthEvent = EventDispatcher() @@ -48,10 +50,58 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { sendDismissIfNeeded() } + override public func layoutSubviews() { + super.layoutSubviews() + guard let logoView = logoState.content?.view else { return } + logoState.updateSize(for: logoView) + } + +#if RCT_NEW_ARCH_ENABLED + override public func mountChildComponentView(_ childComponentView: UIView, index: Int) { + setLogoView(childComponentView) + } + + override public func unmountChildComponentView(_ childComponentView: UIView, index: Int) { + removeLogoView(childComponentView) + } +#else + override public func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + super.insertReactSubview(subview, at: atIndex) + setLogoView(subview) + } + + override public func removeReactSubview(_ subview: UIView!) { + removeLogoView(subview) + super.removeReactSubview(subview) + } + + override public func didUpdateReactSubviews() {} +#endif + + private func setLogoView(_ view: UIView) { + guard logoState.content?.view !== view else { return } + logoBoundsObservation = nil + logoState.setView(view) + logoBoundsObservation = view.observe(\.bounds, options: [.new]) { [weak self, weak view] _, _ in + DispatchQueue.main.async { [weak self, weak view] in + guard let self, let view, self.logoState.content?.view === view else { return } + self.logoState.updateSize(for: view) + } + } + } + + private func removeLogoView(_ view: UIView) { + guard logoState.content?.view === view else { return } + logoBoundsObservation = nil + view.removeFromSuperview() + logoState.removeView(view) + } + override func makeHostedController() -> UIViewController? { return ClerkNativeBridge.shared.makeAuthViewController( mode: currentMode, dismissible: currentDismissible, + logoState: logoState, logoMaxHeight: currentLogoMaxHeight, onEvent: { [weak self] event, _ in if event == .dismissed { diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index 4fd66f44569..837457240eb 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -58,5 +58,9 @@ Pod::Spec.new do |s| "ClerkUserProfileNativeView.swift", "ClerkUserButtonNativeView.swift" + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'Tests/**/*.swift' + end + install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index d3b6fca68f9..24fc52e1d7f 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -16,6 +16,32 @@ extension Notification.Name { static let clerkNativeSDKDidConfigure = Notification.Name("com.clerk.expo.native-sdk.did-configure") } +@Observable +final class ClerkInlineAuthLogoState { + struct Content { + let view: UIView + let size: CGSize + } + + private(set) var content: Content? + + func setView(_ view: UIView) { + content = Content(view: view, size: view.bounds.size) + } + + func updateSize(for view: UIView) { + guard content?.view === view else { return } + let currentSize = view.bounds.size + guard content?.size != currentSize else { return } + content = Content(view: view, size: currentSize) + } + + func removeView(_ view: UIView) { + guard content?.view === view else { return } + content = nil + } +} + private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)? @@ -229,6 +255,7 @@ final class ClerkNativeBridge { func makeAuthViewController( mode: String, dismissible: Bool, + logoState: ClerkInlineAuthLogoState, logoMaxHeight: CGFloat?, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { @@ -240,6 +267,7 @@ final class ClerkNativeBridge { dismissible: dismissible, lightTheme: lightTheme, darkTheme: darkTheme, + logoState: logoState, logoMaxHeight: logoMaxHeight ), onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil @@ -467,6 +495,7 @@ struct ClerkInlineAuthWrapperView: View { let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? + let logoState: ClerkInlineAuthLogoState let logoMaxHeight: CGFloat? @Environment(\.colorScheme) private var colorScheme @@ -483,7 +512,12 @@ struct ClerkInlineAuthWrapperView: View { } } - if let logoMaxHeight { + if let logo = logoState.content { + themedView.clerkAppIconView { + ClerkReactLogoView(view: logo.view) + .frame(width: logo.size.width, height: logo.size.height) + } + } else if let logoMaxHeight { themedView.clerkAppIcon(maxHeight: logoMaxHeight) } else { themedView @@ -495,6 +529,45 @@ struct ClerkInlineAuthWrapperView: View { } } +private struct ClerkReactLogoView: UIViewRepresentable { + let view: UIView + + func makeUIView(context: Context) -> ClerkReactLogoContainerView { + return ClerkReactLogoContainerView(contentView: view) + } + + func updateUIView(_ uiView: ClerkReactLogoContainerView, context: Context) { + uiView.setContentView(view) + } +} + +private final class ClerkReactLogoContainerView: UIView { + private var contentView: UIView? + + init(contentView: UIView) { + super.init(frame: .zero) + setContentView(contentView) + } + + required init?(coder: NSCoder) { + return nil + } + + func setContentView(_ view: UIView) { + guard contentView !== view else { return } + contentView?.removeFromSuperview() + view.removeFromSuperview() + contentView = view + addSubview(view) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + contentView?.frame = bounds + } +} + private final class ClerkNativeHostingController: UIHostingController { private let onDismiss: (() -> Void)? private var didSendDismiss = false diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift new file mode 100644 index 00000000000..58d5efc73b5 --- /dev/null +++ b/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift @@ -0,0 +1,30 @@ +#if !RCT_NEW_ARCH_ENABLED +import ExpoModulesCore +import UIKit +import XCTest +import ClerkExpo + +final class ClerkAuthNativeViewPaperTests: XCTestCase { + @MainActor + func testAddingAndRemovingLogoAfterMountKeepsReactChildOutOfHostHierarchy() { + let window = UIWindow(frame: UIScreen.main.bounds) + let viewController = UIViewController() + let authView = ClerkAuthNativeView(appContext: nil) + window.rootViewController = viewController + viewController.view.addSubview(authView) + window.makeKeyAndVisible() + + let logoView = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 40)) + authView.insertReactSubview(logoView, at: 0) + authView.didUpdateReactSubviews() + + XCTAssertFalse(logoView.superview === authView) + + authView.removeReactSubview(logoView) + authView.didUpdateReactSubviews() + + XCTAssertNil(logoView.superview) + window.isHidden = true + } +} +#endif diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index abe933ba51c..3067b9daa7a 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -38,6 +38,7 @@ type AuthNativeEvent = NativeSyntheticEvent>; * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ export function AuthView({ + logo, mode = 'signInOrUp', isDismissible = true, logoMaxHeight, @@ -71,6 +72,15 @@ export function AuthView({ isDismissible={isDismissible} logoMaxHeight={logoMaxHeight} onAuthEvent={handleAuthEvent} - /> + > + {logo ? ( + + {logo} + + ) : null} + ); } diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 9407c92a923..80caab2629d 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -1,3 +1,5 @@ +import type { ReactElement } from 'react'; + /** * Authentication mode that determines which flows are available to the user. * @@ -15,6 +17,15 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * state changes. */ export interface AuthViewProps { + /** + * Replaces the dashboard-configured logo with custom React Native content. + * + * The native authentication UI does not apply sizing, spacing, or accessibility + * attributes to this content. The provided element must define its own layout + * and accessibility behavior. + */ + logo?: ReactElement; + /** * Authentication mode that determines which flows are available. * diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo/src/native/__tests__/AuthView.test.tsx index 1aa3afcaf72..6b3029ecdda 100644 --- a/packages/expo/src/native/__tests__/AuthView.test.tsx +++ b/packages/expo/src/native/__tests__/AuthView.test.tsx @@ -50,4 +50,13 @@ describe('AuthView', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + + test('passes a custom logo to the native auth view as a single child', () => { + const logo = Custom logo; + + render(); + + const props = mocks.NativeClerkAuthView.mock.calls.at(-1)?.[0]; + expect(props.children.props.children).toBe(logo); + }); });