package expo.community.modules.emojisheet import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class EmojiSheetModule : Module() { private var dialog: BottomSheetDialog? = null private var currentPromise: Promise? = null private var bottomSheetContentView: View? = null private var isAnimatingClose = false override fun definition() = ModuleDefinition { Name("EmojiSheet") Events("onSheetOpened") OnCreate { val ctx = appContext.reactContext ?: return@OnCreate EmojiSheetUIView.warmCache(ctx) } AsyncFunction("present") { options: Map, promise: Promise -> val activity = appContext.currentActivity if (activity == null) { promise.resolve(Bundle().apply { putBoolean("cancelled", true) }) return@AsyncFunction } activity.runOnUiThread { presentSheet(options, promise) } } AsyncFunction("dismiss") { appContext.currentActivity?.runOnUiThread { dismissSheet(cancelled = true) } } AsyncFunction("clearRecents") { appContext.reactContext ?.getSharedPreferences("emoji_sheet_frequently_used", android.content.Context.MODE_PRIVATE) ?.edit()?.clear()?.apply() } AsyncFunction("clearSkinTonePreferences") { appContext.reactContext ?.getSharedPreferences("emoji_sheet_skin_tones", android.content.Context.MODE_PRIVATE) ?.edit()?.clear()?.apply() } View(EmojiSheetContentView::class) { Prop("theme") { view: EmojiSheetContentView, theme: String? -> val resolved = if (theme == "system") { val uiMode = view.context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK if (uiMode == android.content.res.Configuration.UI_MODE_NIGHT_YES) "dark" else "light" } else { theme ?: "light" } view.updateTheme(resolved) } Prop("categoryBarPosition") { view: EmojiSheetContentView, position: String? -> view.updateCategoryBarPosition(position ?: "top") } Prop("layoutDirection") { view: EmojiSheetContentView, direction: String? -> view.updateLayoutDirection(direction ?: "auto") } Prop("columns") { view: EmojiSheetContentView, columns: Int? -> view.updateColumns(columns ?: 7) } Prop("emojiSize") { view: EmojiSheetContentView, size: Double? -> view.updateEmojiSize(size?.toFloat() ?: 32f) } Prop("recentLimit") { view: EmojiSheetContentView, limit: Int? -> view.updateRecentLimit(limit ?: 30) } Prop("showSearch") { view: EmojiSheetContentView, show: Boolean? -> view.updateShowSearch(show ?: true) } Prop("showRecents") { view: EmojiSheetContentView, show: Boolean? -> view.updateShowRecents(show ?: true) } Prop("enableSkinTones") { view: EmojiSheetContentView, enable: Boolean? -> view.updateEnableSkinTones(enable ?: true) } Prop("enableHaptics") { view: EmojiSheetContentView, enable: Boolean? -> view.updateEnableHaptics(enable ?: true) } Prop("enableAnimations") { view: EmojiSheetContentView, enable: Boolean? -> view.updateEnableAnimations(enable ?: false) } Prop("searchPlaceholder") { view: EmojiSheetContentView, text: String? -> if (text != null) view.updateSearchPlaceholder(text) } Prop("noResultsText") { view: EmojiSheetContentView, text: String? -> if (text != null) view.updateNoResultsText(text) } Prop("categoryNames") { view: EmojiSheetContentView, names: Map? -> if (names != null) view.updateCategoryNames(names) } Prop("excludeEmojis") { view: EmojiSheetContentView, ids: List? -> view.updateExcludeEmojis(ids ?: emptyList()) } Events("onEmojiSelected", "onDismiss", "onOpen") } } private fun presentSheet(options: Map, promise: Promise) { val activity = appContext.currentActivity ?: return // Single-flight guard (mirrors iOS): ignore a second present() while a sheet // is already open so the first promise is never orphaned and dialogs cannot stack. if (currentPromise != null || dialog != null) { promise.resolve(Bundle().apply { putBoolean("cancelled", true) }) return } val themeString = options["theme"] as? String ?: "light" val isDark = when (themeString) { "dark" -> true "system" -> { val uiMode = activity.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK uiMode == android.content.res.Configuration.UI_MODE_NIGHT_YES } else -> false } val density = activity.resources.displayMetrics.density // Parse new options @Suppress("UNCHECKED_CAST") val snapPoints = (options["snapPoints"] as? List<*>)?.mapNotNull { (it as? Number)?.toDouble() } ?: listOf(0.5, 1.0) val columns = (options["columns"] as? Number)?.toInt() ?: 7 val emojiSize = (options["emojiSize"] as? Number)?.toFloat() ?: 32f val showSearch = options["showSearch"] as? Boolean ?: true val showRecents = options["showRecents"] as? Boolean ?: true val enableSkinTones = options["enableSkinTones"] as? Boolean ?: true val enableHaptics = options["enableHaptics"] as? Boolean ?: true val enableAnimations = options["enableAnimations"] as? Boolean ?: false val recentLimit = (options["recentLimit"] as? Number)?.toInt() ?: 30 val gestureEnabled = options["gestureEnabled"] as? Boolean ?: true val backdropOpacity = (options["backdropOpacity"] as? Number)?.toFloat() ?: if (isDark) 0.4f else 0.22f val categoryBarPosition = options["categoryBarPosition"] as? String ?: "top" val layoutDirection = options["layoutDirection"] as? String ?: "auto" @Suppress("UNCHECKED_CAST") val categoryNames = options["categoryNames"] as? Map @Suppress("UNCHECKED_CAST") val excludeEmojis = ((options["excludeEmojis"] as? List<*>)?.filterIsInstance() ?: emptyList()).toSet() // Resolve colors from options or defaults val bgColor = parseColor(options["backgroundColor"] as? String) ?: if (isDark) Color.parseColor("#1A1A2E") else Color.WHITE val searchBgColor = parseColor(options["searchBarBackgroundColor"] as? String) ?: if (isDark) Color.parseColor("#2A2F39") else Color.parseColor("#F0F0F0") val searchTextColor = parseColor(options["searchTextColor"] as? String) val placeholderTextColor = parseColor(options["placeholderTextColor"] as? String) val selectionColor = parseColor(options["selectionColor"] as? String) val textColor = parseColor(options["textColor"] as? String) ?: if (isDark) Color.WHITE else Color.BLACK val textSecondaryColor = parseColor(options["textSecondaryColor"] as? String) ?: if (isDark) 0x99FFFFFF.toInt() else 0x80000000.toInt() val accentColor = parseColor(options["accentColor"] as? String) ?: Color.parseColor("#EA4578") val dividerColor = parseColor(options["dividerColor"] as? String) ?: if (isDark) 0x26FFFFFF.toInt() else Color.parseColor("#E0E0E0") val handleColor = parseColor(options["handleColor"] as? String) ?: if (isDark) 0x66FFFFFF.toInt() else 0x33000000.toInt() val categoryIconColor = parseColor(options["categoryIconColor"] as? String) ?: textSecondaryColor val categoryActiveIconColor = parseColor(options["categoryActiveIconColor"] as? String) ?: accentColor val categoryActiveBackgroundColor = parseColor(options["categoryActiveBackgroundColor"] as? String) ?: dividerColor val categoryBarBackgroundColor = parseColor(options["categoryBarBackgroundColor"] as? String) ?: bgColor val customTheme = EmojiSheetTheme( backgroundColor = bgColor, searchBarBackgroundColor = searchBgColor, textColor = textColor, textSecondaryColor = textSecondaryColor, dividerColor = dividerColor, accentColor = accentColor, categoryIconColor = categoryIconColor, categoryActiveIconColor = categoryActiveIconColor, categoryActiveBackgroundColor = categoryActiveBackgroundColor, handleColor = handleColor, categoryBarBackgroundColor = categoryBarBackgroundColor, searchTextColor = searchTextColor, placeholderTextColor = placeholderTextColor, selectionColor = selectionColor ) val bottomSheet = BottomSheetDialog(activity).apply { if (gestureEnabled) { setCancelable(true) setCanceledOnTouchOutside(true) } else { setCancelable(false) setCanceledOnTouchOutside(false) } } val pickerView = EmojiSheetUIView(activity) // Apply configurable properties pickerView.columns = columns pickerView.emojiSize = emojiSize pickerView.showSearch = showSearch pickerView.showRecents = showRecents pickerView.enableSkinTones = enableSkinTones pickerView.enableHaptics = enableHaptics pickerView.enableAnimations = enableAnimations pickerView.recentLimit = recentLimit pickerView.categoryBarPosition = categoryBarPosition pickerView.layoutDirectionProp = layoutDirection pickerView.categoryNames = categoryNames pickerView.excludeEmojis = excludeEmojis (options["searchPlaceholder"] as? String)?.let { pickerView.searchPlaceholder = it } (options["noResultsText"] as? String)?.let { pickerView.noResultsText = it } pickerView.applyCustomTheme(customTheme) pickerView.applyConfiguration() pickerView.onEmojiSelected = { data -> currentPromise?.resolve(Bundle().apply { putString("emoji", data["emoji"] as? String ?: "") putString("name", data["name"] as? String ?: "") putString("id", data["id"] as? String ?: "") }) currentPromise = null dialog?.dismiss() dialog = null } pickerView.onSearchFocused = { hasFocus -> if (hasFocus) { pickerView.setSheetExpansionInProgress(true) bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED } else { bottomSheet.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED } } pickerView.onScrollIntentUp = { if (bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) { pickerView.setSheetExpansionInProgress(true) bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED } } pickerView.onPullDownAtTopDrag = { distance -> dismissKeyboard() updateSheetDrag(bottomSheet, distance) } pickerView.onPullDownAtTopRelease = { distance, velocity -> finishSheetDrag(bottomSheet, distance, velocity) } // Drag handle val handleTargetHeight = (24 * density).toInt() val handleBar = View(activity).apply { val width = (40 * density).toInt() val height = (4 * density).toInt() layoutParams = FrameLayout.LayoutParams(width, height, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply { topMargin = (8 * density).toInt() } background = GradientDrawable().apply { setColor(handleColor) cornerRadius = height / 2f } } val handleTouchTarget = FrameLayout(activity).apply { layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, handleTargetHeight ) contentDescription = "Dismiss emoji sheet" importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES isClickable = true isFocusable = true setOnClickListener { dismissSheet(cancelled = true) } ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() { override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { super.onInitializeAccessibilityNodeInfo(host, info) info.className = Button::class.java.name info.hintText = "Double tap to dismiss." } }) addView(handleBar) } // Container val halfExpandedRatio = (snapPoints.firstOrNull()?.toFloat() ?: 0.5f).coerceIn(0.05f, 1f) val expandedRatio = maxOf( halfExpandedRatio, (snapPoints.getOrNull(1)?.toFloat() ?: 1f).coerceIn(0.05f, 1f) ) val minSheetHeight = (activity.resources.displayMetrics.heightPixels * halfExpandedRatio).toInt() val cornerRadius = 16 * density val container = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL minimumHeight = minSheetHeight layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) background = GradientDrawable().apply { setColor(bgColor) cornerRadii = floatArrayOf(cornerRadius, cornerRadius, cornerRadius, cornerRadius, 0f, 0f, 0f, 0f) } clipToOutline = true outlineProvider = android.view.ViewOutlineProvider.BACKGROUND addView(handleTouchTarget) val pickerLp = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f ) addView(pickerView, pickerLp) } bottomSheet.setContentView(container) var wasKeyboardVisible = false val applyDialogInsets: (WindowInsetsCompat) -> Unit = { insets -> val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) val keyboardVisible = imeInsets.bottom > navigationInsets.bottom container.setPadding( systemBarInsets.left, 0, systemBarInsets.right, if (keyboardVisible) 0 else systemBarInsets.bottom ) updateSheetForKeyboard( bottomSheet, pickerView, minSheetHeight, expandedRatio, imeInsets.bottom, navigationInsets.bottom ) if (keyboardVisible && bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) { pickerView.setSheetExpansionInProgress(true) bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED } val shouldRestoreInitialSnap = wasKeyboardVisible && !keyboardVisible && bottomSheet.behavior.state != BottomSheetBehavior.STATE_HIDDEN if (shouldRestoreInitialSnap) { bottomSheet.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED } wasKeyboardVisible = keyboardVisible } ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets -> applyDialogInsets(insets) insets } // Strip ALL backgrounds from BottomSheet internals bottomSheet.setOnShowListener { dlg -> val d = dlg as BottomSheetDialog val bottomSheetInternal = d.findViewById(com.google.android.material.R.id.design_bottom_sheet) bottomSheetContentView = bottomSheetInternal d.findViewById(com.google.android.material.R.id.container)?.fitsSystemWindows = false d.findViewById(com.google.android.material.R.id.coordinator)?.fitsSystemWindows = false bottomSheetInternal?.apply { fitsSystemWindows = false setBackgroundColor(Color.TRANSPARENT) (parent as? View)?.setBackgroundColor(Color.TRANSPARENT) ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> applyDialogInsets(insets) insets } } bottomSheet.window?.let { window -> WindowCompat.setDecorFitsSystemWindows(window, false) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) } ViewCompat.requestApplyInsets(container) bottomSheetInternal?.let { ViewCompat.requestApplyInsets(it) } pickerView.loadDataAsync() sendEvent("onSheetOpened", android.os.Bundle()) } bottomSheet.window?.let { window -> WindowCompat.setDecorFitsSystemWindows(window, false) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) } bottomSheet.window?.setDimAmount(backdropOpacity) bottomSheet.behavior.apply { state = BottomSheetBehavior.STATE_HALF_EXPANDED this.halfExpandedRatio = halfExpandedRatio expandedOffset = (activity.resources.displayMetrics.heightPixels * (1f - expandedRatio)).toInt() peekHeight = minSheetHeight isHideable = true skipCollapsed = false isFitToContents = false isDraggable = gestureEnabled addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_DRAGGING) { dismissKeyboard() } pickerView.setSheetExpanded(newState == BottomSheetBehavior.STATE_EXPANDED) pickerView.setSheetExpansionInProgress( newState == BottomSheetBehavior.STATE_DRAGGING || newState == BottomSheetBehavior.STATE_SETTLING ) } override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit }) } bottomSheet.setOnDismissListener { bottomSheetContentView = null isAnimatingClose = false currentPromise?.resolve(Bundle().apply { putBoolean("cancelled", true) }) currentPromise = null dialog = null } currentPromise = promise dialog = bottomSheet bottomSheet.show() } private fun dismissSheet(cancelled: Boolean) { if (isAnimatingClose) { return } if (cancelled) { currentPromise?.resolve(Bundle().apply { putBoolean("cancelled", true) }) currentPromise = null } bottomSheetContentView = null dialog?.dismiss() dialog = null } private fun dismissKeyboard() { val activity = appContext.currentActivity ?: return val focusedView = activity.currentFocus val tokenView = focusedView ?: bottomSheetContentView ?: dialog?.window?.decorView ?: return val inputMethodManager = tokenView.context .getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return inputMethodManager.hideSoftInputFromWindow(tokenView.windowToken, 0) focusedView?.clearFocus() } private fun updateSheetForKeyboard( bottomSheet: BottomSheetDialog, pickerView: EmojiSheetUIView, minSheetHeight: Int, expandedRatio: Float, imeBottom: Int, navigationBottom: Int ) { val keyboardHeight = if (imeBottom > navigationBottom) imeBottom else 0 val keyboardVisible = keyboardHeight > 0 val sheetView = bottomSheetContentView ?: bottomSheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) pickerView.setKeyboardDockedToSheet(keyboardVisible) if (sheetView == null) return val parentHeight = ((sheetView.parent as? View)?.height ?: 0) .takeIf { it > 0 } ?: sheetView.rootView.height.takeIf { it > 0 } ?: sheetView.resources.displayMetrics.heightPixels val desiredExpandedHeight = maxOf(1, (parentHeight * expandedRatio).toInt()) val targetHeight = if (keyboardVisible) { minOf(maxOf(1, parentHeight - keyboardHeight), desiredExpandedHeight) } else { ViewGroup.LayoutParams.MATCH_PARENT } bottomSheet.behavior.expandedOffset = if (keyboardVisible) { maxOf(0, parentHeight - keyboardHeight - targetHeight) } else { maxOf(0, parentHeight - desiredExpandedHeight) } val layoutParams = sheetView.layoutParams if (layoutParams.height != targetHeight) { layoutParams.height = targetHeight sheetView.layoutParams = layoutParams } sheetView.translationY = 0f bottomSheet.behavior.peekHeight = minSheetHeight sheetView.requestLayout() } private fun updateSheetDrag(bottomSheet: BottomSheetDialog, distance: Float) { val sheetView = bottomSheetContentView ?: bottomSheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return if (bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) { return } sheetView.animate().cancel() sheetView.translationY = distance.coerceAtLeast(0f) } private fun finishSheetDrag(bottomSheet: BottomSheetDialog, distance: Float, velocityY: Float) { val sheetView = bottomSheetContentView ?: bottomSheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: run { dismissSheet(cancelled = true) return } if (bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) { sheetView.translationY = 0f return } val dismissalThreshold = (sheetView.height * 0.42f).takeIf { it > 0f } ?: (sheetView.resources.displayMetrics.heightPixels * 0.42f) if (distance >= dismissalThreshold || velocityY >= 1400f) { animateSheetDismiss(bottomSheet) return } sheetView.animate() .translationY(0f) .setDuration(180) .setInterpolator(AccelerateDecelerateInterpolator()) .start() } private fun animateSheetDismiss(bottomSheet: BottomSheetDialog) { if (isAnimatingClose) return val sheetView = bottomSheetContentView ?: bottomSheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) if (sheetView == null) { dismissSheet(cancelled = true) return } isAnimatingClose = true sheetView.animate().cancel() sheetView.animate() .translationY(sheetView.height.toFloat().takeIf { it > 0 } ?: sheetView.resources.displayMetrics.heightPixels.toFloat()) .setDuration(220) .setInterpolator(AccelerateDecelerateInterpolator()) .withEndAction { isAnimatingClose = false dismissSheet(cancelled = true) } .start() } private fun parseColor(hex: String?): Int? { if (hex == null) return null val trimmed = hex.trim() // 8-digit hex is #RRGGBBAA (CSS / iOS order). Android's Color.parseColor reads // 8-digit as #AARRGGBB, so parse it manually to keep both platforms in sync. if (trimmed.startsWith("#") && trimmed.length == 9) { val value = trimmed.substring(1).toLongOrNull(16) ?: return null val r = ((value shr 24) and 0xFF).toInt() val g = ((value shr 16) and 0xFF).toInt() val b = ((value shr 8) and 0xFF).toInt() val a = (value and 0xFF).toInt() return Color.argb(a, r, g, b) } return try { Color.parseColor(trimmed) } catch (e: IllegalArgumentException) { null } } }