From e4c80be968e9be284a4ec16f0091e50818e82294 Mon Sep 17 00:00:00 2001 From: ceo Date: Sat, 20 Dec 2025 22:39:32 +0900 Subject: [PATCH 001/267] [ui][Android] Refactor expo-ui components to DSL pattern - Migrate Button, Chip, IconButton, Shape, and other components to DSL pattern - Fix modifiers passing: remove __expo_shared_object_id__ mapping for JSON Config - Restore onSizeChanged in HostView for proper React Native layout integration - ContextMenu remains class-based due to positioning requirements --- .../screens/UI/ContextMenuScreen.android.tsx | 152 ++++++----- .../UI/DateTimePickerScreen.android.tsx | 9 +- packages/expo-modules-core/CHANGELOG.md | 1 + .../modules/kotlin/views/ComposeViewProp.kt | 6 +- packages/expo-ui/CHANGELOG.md | 6 + .../java/expo/modules/ui/AlertDialogView.kt | 84 +++--- .../java/expo/modules/ui/BottomSheetView.kt | 1 - .../main/java/expo/modules/ui/CarouselView.kt | 128 +++++---- .../src/main/java/expo/modules/ui/ChipView.kt | 227 +++++++--------- .../main/java/expo/modules/ui/ComposeViews.kt | 107 +++----- .../java/expo/modules/ui/DatePickerView.kt | 77 +++--- .../main/java/expo/modules/ui/DividerView.kt | 17 ++ .../main/java/expo/modules/ui/ExpoUIModule.kt | 253 ++++++------------ .../src/main/java/expo/modules/ui/HostView.kt | 111 +++++++- .../java/expo/modules/ui/ModifierRegistry.kt | 246 +++++++++++++++++ .../main/java/expo/modules/ui/Modifiers.kt | 24 -- .../main/java/expo/modules/ui/PickerView.kt | 170 ++++++------ .../main/java/expo/modules/ui/ProgressView.kt | 176 ++++++------ .../main/java/expo/modules/ui/ShapeView.kt | 101 +++---- .../main/java/expo/modules/ui/SliderView.kt | 77 +++--- .../main/java/expo/modules/ui/SwitchView.kt | 46 ++-- .../java/expo/modules/ui/TextInputView.kt | 4 +- .../java/expo/modules/ui/button/Button.kt | 120 ++++----- .../java/expo/modules/ui/button/IconButton.kt | 174 ++++++------ .../java/expo/modules/ui/menu/ContextMenu.kt | 78 +++--- .../modules/ui/menu/ContextMenuRecords.kt | 11 +- .../jetpack-compose/AlertDialog/index.d.ts | 18 ++ .../AlertDialog/index.d.ts.map | 2 +- .../build/jetpack-compose/Button/index.d.ts | 4 +- .../jetpack-compose/Button/index.d.ts.map | 2 +- .../jetpack-compose/Carousel/index.d.ts.map | 2 +- .../build/jetpack-compose/Chip/index.d.ts.map | 2 +- .../jetpack-compose/ContextMenu/index.d.ts | 4 +- .../ContextMenu/index.d.ts.map | 2 +- .../jetpack-compose/DatePicker/index.d.ts.map | 2 +- .../build/jetpack-compose/Divider/index.d.ts | 12 + .../jetpack-compose/Divider/index.d.ts.map | 1 + .../build/jetpack-compose/Host/index.d.ts | 17 ++ .../build/jetpack-compose/Host/index.d.ts.map | 2 +- .../jetpack-compose/IconButton/index.d.ts | 4 +- .../jetpack-compose/IconButton/index.d.ts.map | 2 +- .../jetpack-compose/Picker/index.d.ts.map | 2 +- .../jetpack-compose/Progress/index.d.ts.map | 2 +- .../build/jetpack-compose/Shape/index.d.ts | 11 +- .../jetpack-compose/Shape/index.d.ts.map | 2 +- .../jetpack-compose/Slider/index.d.ts.map | 2 +- .../jetpack-compose/Switch/index.d.ts.map | 2 +- .../jetpack-compose/TextInput/index.d.ts.map | 2 +- .../expo-ui/build/jetpack-compose/index.d.ts | 1 + .../build/jetpack-compose/index.d.ts.map | 2 +- .../build/jetpack-compose/layout.d.ts.map | 2 +- .../build/jetpack-compose/modifiers.d.ts | 145 ++++++++-- .../build/jetpack-compose/modifiers.d.ts.map | 2 +- packages/expo-ui/build/types.d.ts | 15 +- packages/expo-ui/build/types.d.ts.map | 2 +- .../src/jetpack-compose/AlertDialog/index.tsx | 27 +- .../src/jetpack-compose/Button/index.tsx | 6 +- .../src/jetpack-compose/Carousel/index.tsx | 6 +- .../src/jetpack-compose/Chip/index.tsx | 7 +- .../src/jetpack-compose/ContextMenu/index.tsx | 9 +- .../src/jetpack-compose/DatePicker/index.tsx | 2 - .../src/jetpack-compose/Divider/index.tsx | 22 ++ .../src/jetpack-compose/Host/index.tsx | 30 ++- .../src/jetpack-compose/IconButton/index.tsx | 6 +- .../src/jetpack-compose/Picker/index.tsx | 4 - .../src/jetpack-compose/Progress/index.tsx | 32 +-- .../src/jetpack-compose/Shape/index.tsx | 76 +++--- .../src/jetpack-compose/Slider/index.tsx | 2 - .../src/jetpack-compose/Switch/index.tsx | 2 - .../src/jetpack-compose/TextInput/index.tsx | 2 - packages/expo-ui/src/jetpack-compose/index.ts | 1 + .../expo-ui/src/jetpack-compose/layout.tsx | 34 +-- .../expo-ui/src/jetpack-compose/modifiers.ts | 248 +++++++++++++++-- packages/expo-ui/src/types.ts | 18 +- 74 files changed, 1818 insertions(+), 1390 deletions(-) create mode 100644 packages/expo-ui/android/src/main/java/expo/modules/ui/DividerView.kt create mode 100644 packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt create mode 100644 packages/expo-ui/build/jetpack-compose/Divider/index.d.ts create mode 100644 packages/expo-ui/build/jetpack-compose/Divider/index.d.ts.map create mode 100644 packages/expo-ui/src/jetpack-compose/Divider/index.tsx diff --git a/apps/native-component-list/src/screens/UI/ContextMenuScreen.android.tsx b/apps/native-component-list/src/screens/UI/ContextMenuScreen.android.tsx index 47c53f0c6a8c85..3962617641c251 100644 --- a/apps/native-component-list/src/screens/UI/ContextMenuScreen.android.tsx +++ b/apps/native-component-list/src/screens/UI/ContextMenuScreen.android.tsx @@ -1,4 +1,4 @@ -import { Button, Switch, ContextMenu, Submenu } from '@expo/ui/jetpack-compose'; +import { Button, Switch, ContextMenu, Submenu, Host } from '@expo/ui/jetpack-compose'; // import { useVideoPlayer, VideoView } from 'expo-video'; import * as React from 'react'; import { View, /* StyleSheet, */ Text } from 'react-native'; @@ -28,91 +28,95 @@ export default function ContextMenuScreen() { Theme - + + + + + + + + + + + + + + +
+ + - + + - - + /> - -
-
- - - - - - - - - - - +
diff --git a/apps/native-component-list/src/screens/UI/DateTimePickerScreen.android.tsx b/apps/native-component-list/src/screens/UI/DateTimePickerScreen.android.tsx index baa25d7351938a..c47827a4a4a1fb 100644 --- a/apps/native-component-list/src/screens/UI/DateTimePickerScreen.android.tsx +++ b/apps/native-component-list/src/screens/UI/DateTimePickerScreen.android.tsx @@ -1,5 +1,10 @@ -import { DateTimePicker, DateTimePickerProps, Picker, Column } from '@expo/ui/jetpack-compose'; -import { Host } from '@expo/ui/swift-ui'; +import { + DateTimePicker, + DateTimePickerProps, + Picker, + Column, + Host, +} from '@expo/ui/jetpack-compose'; import * as React from 'react'; import { ScrollView, Text } from 'react-native'; diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index 4f1187cf3acb7f..7ab25861414ea8 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -31,6 +31,7 @@ ### 🐛 Bug fixes +- [Android] Fixed DSL view props using stale state when updating. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) - [android] Fix source sets for events for functional view definitions. ([#41685](https://github.com/expo/expo/pull/41685) by [@aleqsio](https://github.com/aleqsio)) - [iOS] Fix throwing `InvalidArgsNumberException` when declaring `AsyncFunction` with optional arguments and `Promise`. ([#41054](https://github.com/expo/expo/pull/41054) by [@Wenszel](https://github.com/Wenszel)) - [iOS] fix queue assertion crash ([#41296](https://github.com/expo/expo/pull/41296) by [@vonovak](https://github.com/vonovak)) diff --git a/packages/expo-modules-core/android/src/compose/expo/modules/kotlin/views/ComposeViewProp.kt b/packages/expo-modules-core/android/src/compose/expo/modules/kotlin/views/ComposeViewProp.kt index 8ab70ac8e963fb..739e7abe46f374 100644 --- a/packages/expo-modules-core/android/src/compose/expo/modules/kotlin/views/ComposeViewProp.kt +++ b/packages/expo-modules-core/android/src/compose/expo/modules/kotlin/views/ComposeViewProp.kt @@ -26,14 +26,16 @@ class ComposeViewProp( val props = (onView as ExpoComposeView<*>).props ?: return@exceptionDecorator if (onView is ComposeFunctionHolder<*>) { - val copy = props::class.memberFunctions.firstOrNull { it.name == "copy" } + // Use current props state, not the initial props instance + val currentProps = onView.propsMutableState.value + val copy = currentProps::class.memberFunctions.firstOrNull { it.name == "copy" } if (copy == null) { logger.warn("⚠️ Props are not a data class with default values for all properties, cannot set prop $name dynamically.") return@exceptionDecorator } val instanceParam = copy.instanceParameter!! val newPropParam = copy.parameters.firstOrNull { it.name == name } ?: return@exceptionDecorator - val result = copy.callBy(mapOf(instanceParam to props, newPropParam to type.convert(prop, appContext))) + val result = copy.callBy(mapOf(instanceParam to currentProps, newPropParam to type.convert(prop, appContext))) // Set the new props instance back to the onView (onView.propsMutableState as MutableState).value = result return@exceptionDecorator diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md index be07d8b560c837..2e30960fe4a9f8 100644 --- a/packages/expo-ui/CHANGELOG.md +++ b/packages/expo-ui/CHANGELOG.md @@ -24,10 +24,16 @@ ### 🐛 Bug fixes +- [jetpack-compose] Fixed `DatePicker` and `Picker` crash when used inside `Host` with `matchContents`. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) +- [jetpack-compose] Fixed `Picker` crash when selecting an option. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) +- [jetpack-compose] Fixed `Carousel` not displaying items. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) +- [jetpack-compose] Fixed modifiers not being applied correctly. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) + ### 💡 Others - [jetpack-compose] Replaced `DynamicTheme` as `Host.colorScheme` prop. ([#41413](https://github.com/expo/expo/pull/41413) by [@kudo](https://github.com/kudo)) - [jetpack-compose] Removed coupled `AutoSizingComposable`. ([#41595](https://github.com/expo/expo/pull/41595) by [@kudo](https://github.com/kudo)) +- [jetpack-compose] Refactored leaf components to DSL pattern. ([#41622](https://github.com/expo/expo/pull/41622) by [@kimchi-developer](https://github.com/kimchi-developer)) ## 0.2.0-beta.10 — 2025-12-09 diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/AlertDialogView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/AlertDialogView.kt index 46d5b30f91d1c0..7a6c265af875b9 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/AlertDialogView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/AlertDialogView.kt @@ -1,69 +1,53 @@ package expo.modules.ui -import android.content.Context import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.views.ComposeProps import androidx.compose.ui.Modifier -import expo.modules.kotlin.views.ExpoComposeView -import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.records.Record -import expo.modules.kotlin.views.ComposableScope +import expo.modules.kotlin.views.ComposeProps +import expo.modules.kotlin.views.ExpoViewComposableScope import java.io.Serializable open class AlertDialogButtonPressedEvent() : Record, Serializable data class AlertDialogProps( - val title: MutableState = mutableStateOf(null), - val text: MutableState = mutableStateOf(null), - val confirmButtonText: MutableState = mutableStateOf(null), - val dismissButtonText: MutableState = mutableStateOf(null), - val visible: MutableState = mutableStateOf(false), - val modifiers: MutableState> = mutableStateOf(emptyList()) + val title: String? = null, + val text: String? = null, + val confirmButtonText: String? = null, + val dismissButtonText: String? = null, + val visible: Boolean = false, + val modifiers: List = emptyList() ) : ComposeProps -class AlertDialogView(context: Context, appContext: AppContext) : - ExpoComposeView(context, appContext) { - override val props = AlertDialogProps() - private val onDismissPressed by EventDispatcher() - private val onConfirmPressed by EventDispatcher() - - @Composable - override fun ComposableScope.Content() { - val (title) = props.title - val (text) = props.text - val (confirmButtonText) = props.confirmButtonText - val (dismissButtonText) = props.dismissButtonText - val (visible) = props.visible - - if (!visible) { - return - } +@Composable +fun ExpoViewComposableScope.AlertDialogContent( + props: AlertDialogProps, + onDismissPressed: (AlertDialogButtonPressedEvent) -> Unit, + onConfirmPressed: (AlertDialogButtonPressedEvent) -> Unit +) { + if (!props.visible) { + return + } - AlertDialog( - modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content), - confirmButton = { - confirmButtonText?.let { - TextButton(onClick = { onConfirmPressed.invoke(AlertDialogButtonPressedEvent()) }) { - Text(it) - } + AlertDialog( + confirmButton = { + props.confirmButtonText?.let { + TextButton(onClick = { onConfirmPressed(AlertDialogButtonPressedEvent()) }) { + Text(it) } - }, - dismissButton = { - dismissButtonText?.let { - TextButton(onClick = { onDismissPressed.invoke(AlertDialogButtonPressedEvent()) }) { - Text(it) - } + } + }, + dismissButton = { + props.dismissButtonText?.let { + TextButton(onClick = { onDismissPressed(AlertDialogButtonPressedEvent()) }) { + Text(it) } - }, - onDismissRequest = { onDismissPressed.invoke(AlertDialogButtonPressedEvent()) }, - title = { title?.let { Text(it) } }, - text = { text?.let { Text(it) } } - ) - } + } + }, + onDismissRequest = { onDismissPressed(AlertDialogButtonPressedEvent()) }, + title = { props.title?.let { Text(it) } }, + text = { props.text?.let { Text(it) } } + ) } diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/BottomSheetView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/BottomSheetView.kt index 8ef8702102a058..04d955ef4fbe5c 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/BottomSheetView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/BottomSheetView.kt @@ -2,7 +2,6 @@ package expo.modules.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/CarouselView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/CarouselView.kt index 4956e4e0c1461b..3b1530ad7ea57a 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/CarouselView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/CarouselView.kt @@ -2,7 +2,6 @@ package expo.modules.ui -import android.content.Context import androidx.compose.foundation.gestures.TargetedFlingBehavior import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ExperimentalMaterial3Api @@ -11,19 +10,16 @@ import androidx.compose.material3.carousel.HorizontalUncontainedCarousel import androidx.compose.material3.carousel.rememberCarouselState import androidx.compose.material3.carousel.CarouselDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.core.view.size -import expo.modules.kotlin.AppContext import expo.modules.kotlin.apifeatures.EitherType import expo.modules.kotlin.types.Enumerable import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record import expo.modules.kotlin.types.Either +import androidx.core.view.size import expo.modules.kotlin.views.ComposeProps -import expo.modules.kotlin.views.ExpoComposeView +import expo.modules.kotlin.views.ExpoViewComposableScope import expo.modules.kotlin.views.ComposableScope enum class CarouselVariant(val value: String) : Enumerable { @@ -72,15 +68,15 @@ fun paddingValuesFromEither(either: Either?): Paddin } data class CarouselProps( - val variant: MutableState = mutableStateOf(null), - val modifiers: MutableState?> = mutableStateOf(null), - val itemSpacing: MutableState = mutableStateOf(null), - val contentPadding: MutableState?> = mutableStateOf(null), - val minSmallItemWidth: MutableState = mutableStateOf(null), - val maxSmallItemWidth: MutableState = mutableStateOf(null), - val flingBehavior: MutableState = mutableStateOf(null), - val preferredItemWidth: MutableState = mutableStateOf(null), - val itemWidth: MutableState = mutableStateOf(null) + val variant: CarouselVariant? = null, + val modifiers: List? = null, + val itemSpacing: Float? = null, + val contentPadding: Either? = null, + val minSmallItemWidth: Float? = null, + val maxSmallItemWidth: Float? = null, + val flingBehavior: FlingBehaviorType? = null, + val preferredItemWidth: Float? = null, + val itemWidth: Float? = null ) : ComposeProps const val DEFAULT_MIN_SMALL_ITEM_WIDTH = 40f @@ -88,64 +84,60 @@ const val DEFAULT_MAX_SMALL_ITEM_WIDTH = 56f const val DEFAULT_PREFERRED_ITEM_WIDTH = 200f const val DEFAULT_ITEM_WIDTH = 200f -class CarouselView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext) { - override val props = CarouselProps() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpoViewComposableScope.CarouselContent(props: CarouselProps) { + val variant = props.variant ?: CarouselVariant.MULTI_BROWSE + val modifiers = props.modifiers ?: emptyList() + val itemSpacing = (props.itemSpacing ?: 0f).dp + val minSmallItemWidth = (props.minSmallItemWidth ?: DEFAULT_MIN_SMALL_ITEM_WIDTH).dp + + // we need to constrain maxSmallItemWidth to be at least minSmallItemWidth or the app will crash + val maxSmallItemWidth = minSmallItemWidth.coerceAtLeast((props.maxSmallItemWidth ?: DEFAULT_MAX_SMALL_ITEM_WIDTH).dp) + val preferredItemWidth = (props.preferredItemWidth ?: DEFAULT_PREFERRED_ITEM_WIDTH).dp + val itemWidth = (props.itemWidth ?: DEFAULT_ITEM_WIDTH).dp + val flingBehaviorType = props.flingBehavior ?: FlingBehaviorType.SINGLE_ADVANCE + val contentPadding = paddingValuesFromEither(props.contentPadding) + + val carouselState = rememberCarouselState(0) { view.size } + + val flingBehavior: TargetedFlingBehavior = when (flingBehaviorType) { + FlingBehaviorType.SINGLE_ADVANCE -> CarouselDefaults.singleAdvanceFlingBehavior(state = carouselState) + FlingBehaviorType.NO_SNAP -> CarouselDefaults.noSnapFlingBehavior() + } - @OptIn(ExperimentalMaterial3Api::class) @Composable - override fun ComposableScope.Content() { - val variant = props.variant.value ?: CarouselVariant.MULTI_BROWSE - val modifiers = props.modifiers.value ?: emptyList() - val itemSpacing = (props.itemSpacing.value ?: 0f).dp - val minSmallItemWidth = (props.minSmallItemWidth.value ?: DEFAULT_MIN_SMALL_ITEM_WIDTH).dp - - // we need to constrain maxSmallItemWidth to be at least minSmallItemWidth or the app will crash - val maxSmallItemWidth = minSmallItemWidth.coerceAtLeast((props.maxSmallItemWidth.value ?: DEFAULT_MAX_SMALL_ITEM_WIDTH).dp) - val preferredItemWidth = (props.preferredItemWidth.value ?: DEFAULT_PREFERRED_ITEM_WIDTH).dp - val itemWidth = (props.itemWidth.value ?: DEFAULT_ITEM_WIDTH).dp - val flingBehaviorType = props.flingBehavior.value ?: FlingBehaviorType.SINGLE_ADVANCE - val contentPadding = paddingValuesFromEither(props.contentPadding.value) - - val carouselState = rememberCarouselState(0) { size } - - val flingBehavior: TargetedFlingBehavior = when (flingBehaviorType) { - FlingBehaviorType.SINGLE_ADVANCE -> CarouselDefaults.singleAdvanceFlingBehavior(state = carouselState) - FlingBehaviorType.NO_SNAP -> CarouselDefaults.noSnapFlingBehavior() - } - - @Composable - fun MultiBrowseCarouselComposable() { - HorizontalMultiBrowseCarousel( - state = carouselState, - preferredItemWidth = preferredItemWidth, - modifier = Modifier.fromExpoModifiers(modifiers, this@Content), - itemSpacing = itemSpacing, - flingBehavior = flingBehavior, - minSmallItemWidth = minSmallItemWidth, - maxSmallItemWidth = maxSmallItemWidth, - contentPadding = contentPadding - ) { itemIndex -> - Child(ComposableScope(), itemIndex) - } + fun MultiBrowseCarouselComposable() { + HorizontalMultiBrowseCarousel( + state = carouselState, + preferredItemWidth = preferredItemWidth, + modifier = ModifierRegistry.applyModifiers(modifiers), + itemSpacing = itemSpacing, + flingBehavior = flingBehavior, + minSmallItemWidth = minSmallItemWidth, + maxSmallItemWidth = maxSmallItemWidth, + contentPadding = contentPadding + ) { itemIndex -> + Child(ComposableScope(), itemIndex) } + } - @Composable - fun UnconstrainedCarouselComposable() { - HorizontalUncontainedCarousel( - state = carouselState, - itemWidth = itemWidth, - modifier = Modifier.fromExpoModifiers(modifiers, this@Content), - itemSpacing = itemSpacing, - flingBehavior = flingBehavior, - contentPadding = contentPadding - ) { itemIndex -> - Child(ComposableScope(), itemIndex) - } + @Composable + fun UnconstrainedCarouselComposable() { + HorizontalUncontainedCarousel( + state = carouselState, + itemWidth = itemWidth, + modifier = ModifierRegistry.applyModifiers(modifiers), + itemSpacing = itemSpacing, + flingBehavior = flingBehavior, + contentPadding = contentPadding + ) { itemIndex -> + Child(ComposableScope(), itemIndex) } + } - when (variant) { - CarouselVariant.MULTI_BROWSE -> MultiBrowseCarouselComposable() - CarouselVariant.UNCONSTRAINED -> UnconstrainedCarouselComposable() - } + when (variant) { + CarouselVariant.MULTI_BROWSE -> MultiBrowseCarouselComposable() + CarouselVariant.UNCONSTRAINED -> UnconstrainedCarouselComposable() } } diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ChipView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ChipView.kt index 8e7c40219bd43d..46c6b8b0859128 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/ChipView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ChipView.kt @@ -1,6 +1,5 @@ package expo.modules.ui -import android.content.Context import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -9,142 +8,125 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import expo.modules.kotlin.AppContext import expo.modules.kotlin.records.Record -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ComposableScope import expo.modules.kotlin.views.ComposeProps -import expo.modules.kotlin.views.ExpoComposeView +import expo.modules.kotlin.views.ExpoViewComposableScope import java.io.Serializable open class ChipPressedEvent : Record, Serializable data class ChipProps( - val variant: MutableState = mutableStateOf("assist"), - val label: MutableState = mutableStateOf(""), - val leadingIcon: MutableState = mutableStateOf(null), - val trailingIcon: MutableState = mutableStateOf(null), - val iconSize: MutableState = mutableIntStateOf(18), - val textStyle: MutableState = mutableStateOf("labelSmall"), - val enabled: MutableState = mutableStateOf(true), - val selected: MutableState = mutableStateOf(false) + val variant: String = "assist", + val label: String = "", + val leadingIcon: String? = null, + val trailingIcon: String? = null, + val iconSize: Int = 18, + val textStyle: String = "labelSmall", + val enabled: Boolean = true, + val selected: Boolean = false ) : ComposeProps -class ChipView(context: Context, appContext: AppContext) : - ExpoComposeView(context, appContext) { - - override val props = ChipProps() - - private val onPress by EventDispatcher() - private val onDismiss by EventDispatcher() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpoViewComposableScope.ChipContent( + props: ChipProps, + onPress: (ChipPressedEvent) -> Unit, + onDismiss: (ChipPressedEvent) -> Unit +) { + val chipModifier = Modifier + .padding(4.dp) + .wrapContentSize(Alignment.Center) - @OptIn(ExperimentalMaterial3Api::class) @Composable - override fun ComposableScope.Content() { - val variant by props.variant - val label by props.label - val leadingIcon by props.leadingIcon - val trailingIcon by props.trailingIcon - val iconSize by props.iconSize - val textStyle by props.textStyle - val enabled by props.enabled - val selected by props.selected - - val chipModifier = Modifier - .padding(4.dp) - .wrapContentSize(Alignment.Center) - - @Composable - fun AssistChipComposable() { - AssistChip( - onClick = { onPress.invoke(ChipPressedEvent()) }, - label = { ChipText(label = label, textStyle = textStyle) }, - leadingIcon = { - leadingIcon?.let { - ChipIcon(iconName = it, iconSize = iconSize) - } - }, - trailingIcon = { - trailingIcon?.let { - ChipIcon(iconName = it, iconSize = iconSize) - } - }, - enabled = enabled, - colors = AssistChipDefaults.assistChipColors(), - border = AssistChipDefaults.assistChipBorder(enabled = enabled), - modifier = chipModifier - ) - } + fun AssistChipComposable() { + AssistChip( + onClick = { onPress(ChipPressedEvent()) }, + label = { ChipText(label = props.label, textStyle = props.textStyle) }, + leadingIcon = { + props.leadingIcon?.let { + ChipIcon(iconName = it, iconSize = props.iconSize) + } + }, + trailingIcon = { + props.trailingIcon?.let { + ChipIcon(iconName = it, iconSize = props.iconSize) + } + }, + enabled = props.enabled, + colors = AssistChipDefaults.assistChipColors(), + border = AssistChipDefaults.assistChipBorder(enabled = props.enabled), + modifier = chipModifier + ) + } - @Composable - fun FilterChipComposable() { - FilterChip( - onClick = { onPress.invoke(ChipPressedEvent()) }, - label = { ChipText(label = label, textStyle = textStyle) }, - selected = selected, - leadingIcon = if (selected) { - { - ChipIcon(iconName = "filled.Done", iconSize = iconSize) - } - } else { - null - }, - trailingIcon = { - trailingIcon?.let { - ChipIcon(iconName = it, iconSize = iconSize) - } - }, - enabled = enabled, - colors = FilterChipDefaults.filterChipColors(), - border = FilterChipDefaults.filterChipBorder(enabled = enabled, selected = selected), - modifier = chipModifier - ) - } + @Composable + fun FilterChipComposable() { + FilterChip( + onClick = { onPress(ChipPressedEvent()) }, + label = { ChipText(label = props.label, textStyle = props.textStyle) }, + selected = props.selected, + leadingIcon = if (props.selected) { + { + ChipIcon(iconName = "filled.Done", iconSize = props.iconSize) + } + } else { + null + }, + trailingIcon = { + props.trailingIcon?.let { + ChipIcon(iconName = it, iconSize = props.iconSize) + } + }, + enabled = props.enabled, + colors = FilterChipDefaults.filterChipColors(), + border = FilterChipDefaults.filterChipBorder(enabled = props.enabled, selected = props.selected), + modifier = chipModifier + ) + } - @Composable - fun InputChipComposable() { - if (!enabled) return - InputChip( - onClick = { onDismiss.invoke(ChipPressedEvent()) }, - label = { ChipText(label = label, textStyle = textStyle) }, - enabled = enabled, - selected = selected, - avatar = { - leadingIcon?.let { - ChipIcon(iconName = it, iconSize = iconSize) - } - }, - trailingIcon = { - ChipIcon( - iconName = trailingIcon ?: "filled.Close", - iconSize = iconSize - ) - }, - modifier = chipModifier - ) - } + @Composable + fun InputChipComposable() { + if (!props.enabled) return + InputChip( + onClick = { onDismiss(ChipPressedEvent()) }, + label = { ChipText(label = props.label, textStyle = props.textStyle) }, + enabled = props.enabled, + selected = props.selected, + avatar = { + props.leadingIcon?.let { + ChipIcon(iconName = it, iconSize = props.iconSize) + } + }, + trailingIcon = { + ChipIcon( + iconName = props.trailingIcon ?: "filled.Close", + iconSize = props.iconSize + ) + }, + modifier = chipModifier + ) + } - @Composable - fun SuggestionChipComposable() { - SuggestionChip( - onClick = { onPress.invoke(ChipPressedEvent()) }, - label = { ChipText(label = label, textStyle = textStyle) }, - icon = { - leadingIcon?.let { - ChipIcon(iconName = it, iconSize = iconSize) - } - }, - modifier = chipModifier - ) - } + @Composable + fun SuggestionChipComposable() { + SuggestionChip( + onClick = { onPress(ChipPressedEvent()) }, + label = { ChipText(label = props.label, textStyle = props.textStyle) }, + icon = { + props.leadingIcon?.let { + ChipIcon(iconName = it, iconSize = props.iconSize) + } + }, + modifier = chipModifier + ) + } - when (variant.lowercase()) { - "assist" -> AssistChipComposable() - "filter" -> FilterChipComposable() - "input" -> InputChipComposable() - "suggestion" -> SuggestionChipComposable() - else -> AssistChipComposable() - } + when (props.variant.lowercase()) { + "assist" -> AssistChipComposable() + "filter" -> FilterChipComposable() + "input" -> InputChipComposable() + "suggestion" -> SuggestionChipComposable() + else -> AssistChipComposable() } } @@ -152,7 +134,6 @@ class ChipView(context: Context, appContext: AppContext) : private fun ChipText(label: String, textStyle: String = "labelSmall") { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() ) { Text( text = label, diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ComposeViews.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ComposeViews.kt index 2a7bbed782c544..663e87bf12eaaf 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/ComposeViews.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ComposeViews.kt @@ -1,6 +1,5 @@ package expo.modules.ui -import android.content.Context import android.graphics.Color as AndroidColor import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -8,19 +7,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import expo.modules.kotlin.AppContext import expo.modules.kotlin.types.Enumerable import expo.modules.kotlin.views.ComposeProps -import expo.modules.kotlin.views.ExpoComposeView import expo.modules.kotlin.views.ComposableScope +import expo.modules.kotlin.views.ExpoViewComposableScope import expo.modules.kotlin.views.with enum class HorizontalArrangement(val value: String) : Enumerable { @@ -92,53 +87,41 @@ enum class VerticalAlignment(val value: String) : Enumerable { } data class LayoutProps( - val horizontalArrangement: MutableState = mutableStateOf(HorizontalArrangement.START), - val verticalArrangement: MutableState = mutableStateOf(VerticalArrangement.TOP), - val horizontalAlignment: MutableState = mutableStateOf(HorizontalAlignment.START), - val verticalAlignment: MutableState = mutableStateOf(VerticalAlignment.TOP), - val modifiers: MutableState?> = mutableStateOf(emptyList()) + val horizontalArrangement: HorizontalArrangement = HorizontalArrangement.START, + val verticalArrangement: VerticalArrangement = VerticalArrangement.TOP, + val horizontalAlignment: HorizontalAlignment = HorizontalAlignment.START, + val verticalAlignment: VerticalAlignment = VerticalAlignment.TOP, + val modifiers: List? = emptyList() ) : ComposeProps -class RowView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext) { - override val props = LayoutProps() - - @Composable - override fun ComposableScope.Content() { - Row( - horizontalArrangement = props.horizontalArrangement.value.toComposeArrangement(), - verticalAlignment = props.verticalAlignment.value.toComposeAlignment(), - modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content) - ) { - Children(this@Content.with(rowScope = this@Row)) - } +@Composable +fun ExpoViewComposableScope.RowContent(props: LayoutProps) { + Row( + horizontalArrangement = props.horizontalArrangement.toComposeArrangement(), + verticalAlignment = props.verticalAlignment.toComposeAlignment(), + modifier = ModifierRegistry.applyModifiers(props.modifiers) + ) { + Children(ComposableScope().with(rowScope = this@Row)) } } -class ColumnView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext) { - override val props = LayoutProps() - - @Composable - override fun ComposableScope.Content() { - Column( - verticalArrangement = props.verticalArrangement.value.toComposeArrangement(), - horizontalAlignment = props.horizontalAlignment.value.toComposeAlignment(), - modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content) - ) { - Children(this@Content.with(columnScope = this@Column)) - } +@Composable +fun ExpoViewComposableScope.ColumnContent(props: LayoutProps) { + Column( + verticalArrangement = props.verticalArrangement.toComposeArrangement(), + horizontalAlignment = props.horizontalAlignment.toComposeAlignment(), + modifier = ModifierRegistry.applyModifiers(props.modifiers) + ) { + Children(ComposableScope().with(columnScope = this@Column)) } } -class BoxView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext) { - override val props = LayoutProps() - - @Composable - override fun ComposableScope.Content() { - Box( - modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content) - ) { - Children(this@Content.with(boxScope = this@Box)) - } +@Composable +fun ExpoViewComposableScope.BoxContent(props: LayoutProps) { + Box( + modifier = ModifierRegistry.applyModifiers(props.modifiers) + ) { + Children(ComposableScope().with(boxScope = this@Box)) } } @@ -173,26 +156,22 @@ enum class TextFontWeight(val value: String) : Enumerable { } data class TextProps( - val text: MutableState = mutableStateOf(""), - val color: MutableState = mutableStateOf(null), - val fontSize: MutableState = mutableFloatStateOf(16f), - val fontWeight: MutableState = mutableStateOf(TextFontWeight.NORMAL), - val modifiers: MutableState> = mutableStateOf(emptyList()) + val text: String = "", + val color: AndroidColor? = null, + val fontSize: Float = 16f, + val fontWeight: TextFontWeight = TextFontWeight.NORMAL, + val modifiers: List = emptyList() ) : ComposeProps -class TextView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext) { - override val props = TextProps() - - @Composable - override fun ComposableScope.Content() { - Text( - text = props.text.value, - modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content), - color = colorToComposeColor(props.color.value), - style = TextStyle( - fontSize = props.fontSize.value.sp, - fontWeight = props.fontWeight.value.toComposeFontWeight() - ) +@Composable +fun ExpoViewComposableScope.TextContent(props: TextProps) { + Text( + text = props.text, + modifier = ModifierRegistry.applyModifiers(props.modifiers), + color = colorToComposeColor(props.color), + style = TextStyle( + fontSize = props.fontSize.sp, + fontWeight = props.fontWeight.toComposeFontWeight() ) - } + ) } diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/DatePickerView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/DatePickerView.kt index 6961e55e905d2f..58f106fc88ccfb 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/DatePickerView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/DatePickerView.kt @@ -1,7 +1,5 @@ package expo.modules.ui -import android.annotation.SuppressLint -import android.content.Context import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DatePickerState @@ -13,19 +11,14 @@ import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.TimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import expo.modules.kotlin.AppContext import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record import expo.modules.kotlin.types.Enumerable -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ComposableScope import expo.modules.kotlin.views.ComposeProps -import expo.modules.kotlin.views.ExpoComposeView +import expo.modules.kotlin.views.ExpoViewComposableScope import java.util.Calendar import java.util.Date import android.graphics.Color as AndroidColor @@ -50,39 +43,31 @@ enum class Variant(val value: String) : Enumerable { return when (this) { PICKER -> DisplayMode.Picker INPUT -> DisplayMode.Input - else -> DisplayMode.Picker } } } data class DateTimePickerProps( - val title: MutableState = mutableStateOf(""), - val initialDate: MutableState = mutableStateOf(null), - val variant: MutableState = mutableStateOf(Variant.PICKER), - val displayedComponents: MutableState = mutableStateOf(DisplayedComponents.DATE), - val showVariantToggle: MutableState = mutableStateOf(true), - val is24Hour: MutableState = mutableStateOf(true), - val color: MutableState = mutableStateOf(null), - val modifiers: MutableState> = mutableStateOf(emptyList()) + val title: String = "", + val initialDate: Long? = null, + val variant: Variant = Variant.PICKER, + val displayedComponents: DisplayedComponents = DisplayedComponents.DATE, + val showVariantToggle: Boolean = true, + val is24Hour: Boolean = true, + val color: AndroidColor? = null, + val modifiers: List = emptyList() ) : ComposeProps -@SuppressLint("ViewConstructor") @OptIn(ExperimentalMaterial3Api::class) -class DateTimePickerView(context: Context, appContext: AppContext) : - ExpoComposeView(context, appContext) { - override val props = DateTimePickerProps() - private val onDateSelected by EventDispatcher() - - @Composable - override fun ComposableScope.Content() { - if (props.displayedComponents.value == DisplayedComponents.HOUR_AND_MINUTE) { - ExpoTimePicker(props = props, modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content)) { - onDateSelected(it) - } - } else { - ExpoDatePicker(props = props, modifier = Modifier.fromExpoModifiers(props.modifiers.value, this@Content)) { - onDateSelected(it) - } +@Composable +fun ExpoViewComposableScope.DateTimePickerContent(props: DateTimePickerProps, onDateSelected: (DatePickerResult) -> Unit) { + if (props.displayedComponents == DisplayedComponents.HOUR_AND_MINUTE) { + ExpoTimePicker(props = props, modifier = ModifierRegistry.applyModifiers(props.modifiers)) { + onDateSelected(it) + } + } else { + ExpoDatePicker(props = props, modifier = ModifierRegistry.applyModifiers(props.modifiers)) { + onDateSelected(it) } } } @@ -91,8 +76,8 @@ class DateTimePickerView(context: Context, appContext: AppContext) : @Composable fun ExpoDatePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, onDateSelected: (DatePickerResult) -> Unit) { val locale = LocalConfiguration.current.locales[0] - val variant = props.variant.value.toDisplayMode() - val initialDate = props.initialDate.value + val variant = props.variant.toDisplayMode() + val initialDate = props.initialDate val state = remember(variant, initialDate) { DatePickerState( @@ -112,12 +97,12 @@ fun ExpoDatePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, on DatePicker( modifier = modifier, state = state, - showModeToggle = props.showVariantToggle.value, + showModeToggle = props.showVariantToggle, colors = DatePickerDefaults.colors().copy( - titleContentColor = colorToComposeColor(props.color.value), - selectedDayContainerColor = colorToComposeColor(props.color.value), - todayDateBorderColor = colorToComposeColor(props.color.value), - headlineContentColor = colorToComposeColor(props.color.value) + titleContentColor = colorToComposeColor(props.color), + selectedDayContainerColor = colorToComposeColor(props.color), + todayDateBorderColor = colorToComposeColor(props.color), + headlineContentColor = colorToComposeColor(props.color) ) ) } @@ -127,8 +112,8 @@ fun ExpoDatePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, on fun ExpoTimePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, onDateSelected: (DatePickerResult) -> Unit) { val cal = Calendar.getInstance() - val state = remember(props.initialDate.value, props.is24Hour.value) { - val initialDate = props.initialDate.value + val state = remember(props.initialDate, props.is24Hour) { + val initialDate = props.initialDate if (initialDate != null) { cal.timeInMillis = initialDate } else { @@ -140,7 +125,7 @@ fun ExpoTimePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, on TimePickerState( initialHour = hour, initialMinute = minute, - is24Hour = props.is24Hour.value + is24Hour = props.is24Hour ) } @@ -157,9 +142,9 @@ fun ExpoTimePicker(modifier: Modifier = Modifier, props: DateTimePickerProps, on state = state, layoutType = TimePickerLayoutType.Vertical, colors = TimePickerDefaults.colors().copy( - selectorColor = colorToComposeColor(props.color.value), - timeSelectorSelectedContainerColor = colorToComposeColor(props.color.value), - clockDialColor = colorToComposeColor(props.color.value).copy(alpha = 0.3f) + selectorColor = colorToComposeColor(props.color), + timeSelectorSelectedContainerColor = colorToComposeColor(props.color), + clockDialColor = colorToComposeColor(props.color).copy(alpha = 0.3f) ) ) } diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/DividerView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/DividerView.kt new file mode 100644 index 00000000000000..77952c5f1340ee --- /dev/null +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/DividerView.kt @@ -0,0 +1,17 @@ +package expo.modules.ui + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import expo.modules.kotlin.views.ComposeProps +import expo.modules.kotlin.views.ExpoViewComposableScope + +data class DividerProps( + val modifiers: List = emptyList() +) : ComposeProps + +@Composable +fun ExpoViewComposableScope.DividerContent(props: DividerProps) { + HorizontalDivider(modifier = ModifierRegistry.applyModifiers(props.modifiers)) +} + diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt index 2a398bd98cb7ed..e95db6e0bb9f36 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt @@ -1,54 +1,25 @@ package expo.modules.ui -import android.graphics.Color -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import expo.modules.kotlin.jni.JavaScriptFunction import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.getValue -import expo.modules.ui.button.Button -import expo.modules.ui.button.IconButton -import expo.modules.ui.menu.ContextMenu -import kotlin.reflect.KProperty +import expo.modules.ui.button.ButtonContent +import expo.modules.ui.button.ButtonPressedEvent +import expo.modules.ui.button.ButtonProps +import expo.modules.ui.button.IconButtonContent +import expo.modules.ui.button.IconButtonProps +import expo.modules.ui.menu.ContextMenuButtonPressedEvent +import expo.modules.ui.menu.ContextMenuContent +import expo.modules.ui.menu.ContextMenuProps +import expo.modules.ui.menu.ContextMenuSwitchValueChangeEvent +import expo.modules.ui.menu.ExpandedChangedEvent class ExpoUIModule : Module() { override fun definition() = ModuleDefinition { Name("ExpoUI") - Class("ExpoModifier", ExpoModifier::class) { - Constructor { - // Create an instance of ExpoModifier with a null reference - ExpoModifier(null) - } - - Function("toString") { ref: ExpoModifier -> - // Return a string representation of the modifier - "ExpoModifier(ref=${ref.ref})" - } - } - View("BottomSheetView", events = { Events("onIsOpenedChange") }) { props: BottomSheetProps -> @@ -57,42 +28,76 @@ class ExpoUIModule : Module() { } // Defines a single view for now – a single choice segmented control - View(PickerView::class) { + View("PickerView", events = { Events("onOptionSelected") + }) { props: PickerProps -> + val onOptionSelected by remember { EventDispatcher() } + PickerContent(props) { onOptionSelected(it) } } - View(SwitchView::class) { + View("SwitchView", events = { Events("onValueChange") + }) { props: SwitchProps -> + val onValueChange by remember { EventDispatcher() } + SwitchContent(props) { onValueChange(it) } } - View(Button::class) { + View("Button", events = { Events("onButtonPressed") + }) { props: ButtonProps -> + val onButtonPressed by remember { EventDispatcher() } + ButtonContent(props) { onButtonPressed(it) } } - View(IconButton::class) { + View("IconButton", events = { Events("onButtonPressed") + }) { props: IconButtonProps -> + val onButtonPressed by remember { EventDispatcher() } + IconButtonContent(props) { onButtonPressed(it) } } - View(SliderView::class) { + View("SliderView", events = { Events("onValueChanged") + }) { props: SliderProps -> + SliderContent(props) } - View(ShapeView::class) + View("ShapeView") { props: ShapeProps -> + ShapeContent(props) + } + + View("DividerView") { props: DividerProps -> + DividerContent(props) + } - View(DateTimePickerView::class) { + View("DateTimePickerView", events = { Events("onDateSelected") + }) { props: DateTimePickerProps -> + val onDateSelected by remember { EventDispatcher() } + DateTimePickerContent(props) { onDateSelected(it) } } - View(ContextMenu::class) { + View("ContextMenuView", events = { Events( "onContextMenuButtonPressed", - "onContextMenuPickerOptionSelected", "onContextMenuSwitchValueChanged", "onExpandedChanged" ) + }) { props: ContextMenuProps -> + val onContextMenuButtonPressed by remember { EventDispatcher() } + val onContextMenuSwitchValueChanged by remember { EventDispatcher() } + val onExpandedChanged by remember { EventDispatcher() } + ContextMenuContent( + props, + { onContextMenuButtonPressed(it) }, + { onContextMenuSwitchValueChanged(it) }, + { onExpandedChanged(it) } + ) } - View(ProgressView::class) + View("ProgressView") { props: ProgressProps -> + ProgressContent(props) + } View(TextInputView::class) { Events("onValueChanged") @@ -106,9 +111,19 @@ class ExpoUIModule : Module() { } } - View(BoxView::class) - View(RowView::class) - View(ColumnView::class) + View("BoxView") { props: LayoutProps -> + BoxContent(props) + } + + View("RowView") { props: LayoutProps -> + RowContent(props) + } + + View("ColumnView") { props: LayoutProps -> + ColumnContent(props) + } + + // HostView kept as class-based due to OnViewDidUpdateProps callback and custom measure logic View(HostView::class) { Events("onLayoutContent") @@ -116,129 +131,39 @@ class ExpoUIModule : Module() { view.onViewDidUpdateProps() } } - View(TextView::class) - View(CarouselView::class) - View(AlertDialogView::class) { + View("TextView") { props: TextProps -> + TextContent(props) + } + + View("CarouselView") { props: CarouselProps -> + CarouselContent(props) + } + + View("AlertDialogView", events = { Events( "onDismissPressed", "onConfirmPressed" ) + }) { props: AlertDialogProps -> + val onDismissPressed by remember { EventDispatcher() } + val onConfirmPressed by remember { EventDispatcher() } + AlertDialogContent( + props, + { onDismissPressed(it) }, + { onConfirmPressed(it) } + ) } - View(ChipView::class) { + View("ChipView", events = { Events( "onPress", "onDismiss" ) + }) { props: ChipProps -> + val onPress by remember { EventDispatcher() } + val onDismiss by remember { EventDispatcher() } + ChipContent(props, { onPress(it) }, { onDismiss(it) }) } - - Function("paddingAll") { all: Int -> - return@Function ExpoModifier(Modifier.padding(all.dp)) - } - - Function("padding") { start: Int, top: Int, end: Int, bottom: Int -> - return@Function ExpoModifier(Modifier.padding(start.dp, top.dp, end.dp, bottom.dp)) - } - - Function("size") { width: Int, height: Int -> - return@Function ExpoModifier(Modifier.size(width.dp, height.dp)) - } - - Function("fillMaxSize") { fraction: Float? -> - return@Function ExpoModifier(Modifier.fillMaxSize(fraction = fraction ?: 1.0f)) - } - - Function("fillMaxWidth") { fraction: Float? -> - return@Function ExpoModifier(Modifier.fillMaxWidth(fraction = fraction ?: 1.0f)) - } - - Function("fillMaxHeight") { fraction: Float? -> - return@Function ExpoModifier(Modifier.fillMaxHeight(fraction = fraction ?: 1.0f)) - } - - Function("offset") { x: Int, y: Int -> - return@Function ExpoModifier(Modifier.offset(x.dp, y.dp)) - } - - Function("background") { color: Color -> - return@Function ExpoModifier(Modifier.background(color.compose)) - } - - Function("border") { borderWidth: Int, borderColor: Color -> - return@Function ExpoModifier(Modifier.border(BorderStroke(borderWidth.dp, borderColor.compose))) - } - - Function("shadow") { elevation: Int -> - return@Function ExpoModifier(Modifier.shadow(elevation.dp)) // TODO: Support more options - } - - Function("alpha") { alpha: Float -> - return@Function ExpoModifier(Modifier.alpha(alpha)) - } - - Function("blur") { radius: Int -> - return@Function ExpoModifier(Modifier.blur(radius.dp)) - } - - Function("clickable") { callback: JavaScriptFunction -> - return@Function ExpoModifier( - Modifier.clickable( - onClick = { - appContext.executeOnJavaScriptThread { - callback.invoke() - } - } - ) - ) - } - - Function("rotate") { degrees: Float -> - return@Function ExpoModifier(Modifier.rotate(degrees)) - } - - Function("zIndex") { index: Float -> - return@Function ExpoModifier(Modifier.zIndex(index)) - } - - Function("animateContentSize") { dampingRatio: Float?, stiffness: Float? -> - return@Function ExpoModifier( - Modifier.animateContentSize( - spring(dampingRatio = dampingRatio ?: Spring.DampingRatioNoBouncy, stiffness = stiffness ?: Spring.StiffnessMedium) - ) - ) - } - - Function("weight") { weight: Float -> - val scopedExpoModifier = ExpoModifier { - it.rowScope?.run { - Modifier.weight(weight) - } ?: it.columnScope?.run { - Modifier.weight(weight) - } ?: Modifier - } - return@Function scopedExpoModifier - } - - Function("matchParentSize") { - val scopedExpoModifier = ExpoModifier { - it.boxScope?.run { - Modifier.matchParentSize() - } ?: Modifier - } - return@Function scopedExpoModifier - } - - Function("testID") { testID: String -> - return@Function ExpoModifier(Modifier.applyTestTag(testID)) - } - - Function("clip") { shapeRecord: ShapeRecord -> - val shape = shapeFromShapeRecord(shapeRecord) - ?: return@Function Modifier - return@Function ExpoModifier(Modifier.clip(shape)) - } - - // TODO: Consider implementing semantics, layoutId, clip, navigationBarsPadding, systemBarsPadding } } diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt index 8df3a5101260f1..e3ed4c5683c6ca 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt @@ -4,6 +4,11 @@ import android.annotation.SuppressLint import android.content.Context import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.ColorScheme @@ -13,16 +18,22 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import expo.modules.kotlin.AppContext import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record @@ -32,6 +43,27 @@ import expo.modules.kotlin.views.ComposableScope import expo.modules.kotlin.views.ComposeProps import expo.modules.kotlin.views.ExpoComposeView +internal enum class ExpoLayoutDirection(val value: String) : Enumerable { + LeftToRight("leftToRight"), + RightToLeft("rightToLeft"); + + fun toLayoutDirection(): LayoutDirection { + return when (this) { + LeftToRight -> LayoutDirection.Ltr + RightToLeft -> LayoutDirection.Rtl + } + } +} + +internal data class HostProps( + val colorScheme: MutableState = mutableStateOf(null), + val layoutDirection: MutableState = mutableStateOf(ExpoLayoutDirection.LeftToRight), + val useViewportSizeMeasurement: MutableState = mutableStateOf(false), + val ignoreSafeAreaKeyboardInsets: MutableState = mutableStateOf(false), + val matchContentsHorizontal: MutableState = mutableStateOf(null), + val matchContentsVertical: MutableState = mutableStateOf(null) +) : ComposeProps + internal enum class ExpoColorScheme(val value: String) : Enumerable { LIGHT("light"), DARK("dark"); @@ -56,27 +88,25 @@ internal enum class ExpoColorScheme(val value: String) : Enumerable { } } -internal data class HostProps( - val colorScheme: MutableState = mutableStateOf(null), - val matchContentsHorizontal: MutableState = mutableStateOf(null), - val matchContentsVertical: MutableState = mutableStateOf(null) -) : ComposeProps - @SuppressLint("ViewConstructor") internal class HostView(context: Context, appContext: AppContext) : ExpoComposeView(context, appContext, withHostingView = true) { override val props = HostProps() private val onLayoutContent by EventDispatcher() + private var lastDispatchedContentSize: IntSize? = null @Composable override fun ComposableScope.Content() { val context = LocalContext.current val colorScheme = props.colorScheme.value?.toColorScheme(context) ?: ExpoColorScheme.defaultColorScheme(context, isSystemInDarkTheme()) + val layoutDirection = props.layoutDirection.value.toLayoutDirection() - MaterialTheme(colorScheme = colorScheme) { - MaybeMatchContentsLayout { - Children(this@Content) + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + MaterialTheme(colorScheme = colorScheme) { + MaybeMatchContentsLayout { + Children(this@Content) + } } } } @@ -84,6 +114,23 @@ internal class HostView(context: Context, appContext: AppContext) : @Composable private fun MaybeMatchContentsLayout(content: @Composable () -> Unit) { val density = LocalDensity.current + val configuration = LocalConfiguration.current + val layoutDirection = LocalLayoutDirection.current + + val screenWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() } + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() } + + val baseInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout) + val viewportInsets = if (props.ignoreSafeAreaKeyboardInsets.value) { + baseInsets + } else { + baseInsets.union(WindowInsets.ime) + } + + val safeWidthPx = (screenWidthPx - baseInsets.getLeft(density, layoutDirection) - baseInsets.getRight(density, layoutDirection)) + .coerceAtLeast(0) + val safeHeightPx = (screenHeightPx - viewportInsets.getTop(density) - viewportInsets.getBottom(density)) + .coerceAtLeast(0) Layout( modifier = Modifier @@ -92,20 +139,56 @@ internal class HostView(context: Context, appContext: AppContext) : .onSizeChanged { size -> dispatchOnLayoutContent(size, density) }, content = content ) { measurables, constraints -> - val placeables = measurables.map { it.measure(constraints) } + val useViewportSizeMeasurement = props.useViewportSizeMeasurement.value - val width = placeables.maxOfOrNull { it.width } ?: 0 - val height = placeables.maxOfOrNull { it.height } ?: 0 + // When matchContents is used, constraints may have infinite maxWidth/maxHeight. + // Some components (like DatePicker, segmented buttons) use horizontal scrolling + // internally and crash with infinite constraints. Bound infinite values to screen size. + val boundedConstraints = Constraints( + minWidth = constraints.minWidth, + maxWidth = when { + constraints.maxWidth == Constraints.Infinity -> safeWidthPx + useViewportSizeMeasurement && constraints.maxWidth == 0 -> safeWidthPx + else -> constraints.maxWidth + }, + minHeight = constraints.minHeight, + maxHeight = when { + constraints.maxHeight == Constraints.Infinity -> safeHeightPx + useViewportSizeMeasurement && constraints.maxHeight == 0 -> safeHeightPx + else -> constraints.maxHeight + } + ) + val placeables = measurables.map { it.measure(boundedConstraints) } + + val contentWidthPx = placeables.maxOfOrNull { it.width } ?: 0 + val contentHeightPx = placeables.maxOfOrNull { it.height } ?: 0 + + if (useViewportSizeMeasurement && (constraints.maxWidth == 0 || constraints.maxHeight == 0)) { + with(density) { + val widthDp = contentWidthPx.toDp().value.toDouble() + val heightDp = contentHeightPx.toDp().value.toDouble() + + shadowNodeProxy.setViewSize( + if (constraints.maxWidth == 0) widthDp else Double.NaN, + if (constraints.maxHeight == 0) heightDp else Double.NaN + ) + } + } - layout(width, height) { + layout(contentWidthPx, contentHeightPx) { placeables.forEach { child -> - child.place(0, 0) + child.placeRelative(0, 0) } } } } private fun dispatchOnLayoutContent(size: IntSize, density: Density) { + if (lastDispatchedContentSize == size) { + return + } + lastDispatchedContentSize = size + val matchContentsHorizontal = this.props.matchContentsHorizontal.value val matchContentsVertical = this.props.matchContentsVertical.value diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt new file mode 100644 index 00000000000000..f7b302bdfece2e --- /dev/null +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt @@ -0,0 +1,246 @@ +package expo.modules.ui + +import android.graphics.Color +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.types.Enumerable +import expo.modules.kotlin.views.ComposableScope + +/** + * Registry for Compose view modifiers that can be applied from React Native. + * This system uses JSON config pattern (like iOS SwiftUI modifiers) instead of SharedRef. + * + * Usage in JS: + * ```typescript + * const mod = paddingAll(10); // Returns { $type: 'paddingAll', all: 10 } + * - {onToggleExpand ? (