expo-native-sheet-emojis 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ package expo.community.modules.emojisheet
3
3
  import android.content.Context
4
4
  import android.os.Build
5
5
  import org.json.JSONArray
6
+ import java.text.Normalizer
7
+ import java.util.Locale
6
8
 
7
9
  data class EmojiItem(
8
10
  val emoji: String,
@@ -10,7 +12,9 @@ data class EmojiItem(
10
12
  val v: String,
11
13
  val toneEnabled: Boolean,
12
14
  val keywords: List<String>,
13
- val id: String
15
+ val id: String,
16
+ val normalizedName: String,
17
+ val normalizedKeywords: List<String>
14
18
  )
15
19
 
16
20
  data class EmojiCategory(
@@ -20,6 +24,8 @@ data class EmojiCategory(
20
24
 
21
25
  object EmojiData {
22
26
 
27
+ private val COMBINING_MARKS_REGEX = "\\p{Mn}+".toRegex()
28
+
23
29
  val categoryDisplayNames = mapOf(
24
30
  "frequently_used" to "Frequently Used",
25
31
  "smileys_emotion" to "Smileys & Emotion",
@@ -53,14 +59,17 @@ object EmojiData {
53
59
  for (k in 0 until keywordsArray.length()) {
54
60
  keywords.add(keywordsArray.getString(k))
55
61
  }
62
+ val name = item.getString("name")
56
63
  items.add(
57
64
  EmojiItem(
58
65
  emoji = item.getString("emoji"),
59
- name = item.getString("name"),
66
+ name = name,
60
67
  v = item.getString("v"),
61
68
  toneEnabled = item.getBoolean("toneEnabled"),
62
69
  keywords = keywords,
63
- id = item.getString("id")
70
+ id = item.getString("id"),
71
+ normalizedName = normalizeSearchText(name),
72
+ normalizedKeywords = normalizedSearchKeywords(keywords)
64
73
  )
65
74
  )
66
75
  }
@@ -84,6 +93,27 @@ object EmojiData {
84
93
  }
85
94
  }
86
95
 
96
+ fun normalizeSearchText(text: String): String {
97
+ val normalized = Normalizer.normalize(text, Normalizer.Form.NFD)
98
+ .replace(COMBINING_MARKS_REGEX, "")
99
+ return normalized
100
+ .trim()
101
+ .lowercase(Locale.ROOT)
102
+ }
103
+
104
+ fun normalizedSearchVariants(text: String): Set<String> {
105
+ return setOf(normalizeSearchText(text)).filter { it.isNotBlank() }.toSet()
106
+ }
107
+
108
+ fun normalizedSearchKeywords(keywords: List<String>): List<String> {
109
+ return keywords
110
+ .asSequence()
111
+ .map(::normalizeSearchText)
112
+ .filter { it.isNotBlank() }
113
+ .distinct()
114
+ .toList()
115
+ }
116
+
87
117
  fun displayName(categoryTitle: String, customNames: Map<String, String>? = null): String {
88
118
  customNames?.get(categoryTitle)?.let { return it }
89
119
  return categoryDisplayNames[categoryTitle] ?: categoryTitle.replace("_", " ")
@@ -19,6 +19,7 @@ class EmojiSheetContentView(
19
19
 
20
20
  init {
21
21
  pickerView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
22
+ pickerView.setSheetExpanded(true)
22
23
  pickerView.onEmojiSelected = { data ->
23
24
  onEmojiSelected(data)
24
25
  }
@@ -8,11 +8,15 @@ import android.view.View
8
8
  import android.view.ViewGroup
9
9
  import android.view.animation.AccelerateDecelerateInterpolator
10
10
  import android.view.WindowManager
11
+ import android.view.inputmethod.InputMethodManager
12
+ import android.widget.Button
13
+ import android.widget.FrameLayout
14
+ import android.widget.LinearLayout
15
+ import androidx.core.view.AccessibilityDelegateCompat
11
16
  import androidx.core.view.ViewCompat
12
17
  import androidx.core.view.WindowCompat
13
18
  import androidx.core.view.WindowInsetsCompat
14
- import android.widget.FrameLayout
15
- import android.widget.LinearLayout
19
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
16
20
  import com.google.android.material.bottomsheet.BottomSheetBehavior
17
21
  import com.google.android.material.bottomsheet.BottomSheetDialog
18
22
  import expo.modules.kotlin.Promise
@@ -241,6 +245,7 @@ class EmojiSheetModule : Module() {
241
245
  }
242
246
  }
243
247
  pickerView.onPullDownAtTopDrag = { distance ->
248
+ dismissKeyboard()
244
249
  updateSheetDrag(bottomSheet, distance)
245
250
  }
246
251
  pickerView.onPullDownAtTopRelease = { distance, velocity ->
@@ -248,22 +253,44 @@ class EmojiSheetModule : Module() {
248
253
  }
249
254
 
250
255
  // Drag handle
256
+ val handleTargetHeight = (24 * density).toInt()
251
257
  val handleBar = View(activity).apply {
252
258
  val width = (40 * density).toInt()
253
259
  val height = (4 * density).toInt()
254
- layoutParams = LinearLayout.LayoutParams(width, height).apply {
255
- gravity = Gravity.CENTER_HORIZONTAL
260
+ layoutParams = FrameLayout.LayoutParams(width, height, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply {
256
261
  topMargin = (8 * density).toInt()
257
- bottomMargin = (4 * density).toInt()
258
262
  }
259
263
  background = GradientDrawable().apply {
260
264
  setColor(handleColor)
261
265
  cornerRadius = height / 2f
262
266
  }
263
267
  }
268
+ val handleTouchTarget = FrameLayout(activity).apply {
269
+ layoutParams = LinearLayout.LayoutParams(
270
+ LinearLayout.LayoutParams.MATCH_PARENT,
271
+ handleTargetHeight
272
+ )
273
+ contentDescription = "Dismiss emoji sheet"
274
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
275
+ isClickable = true
276
+ isFocusable = true
277
+ setOnClickListener { dismissSheet(cancelled = true) }
278
+ ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
279
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
280
+ super.onInitializeAccessibilityNodeInfo(host, info)
281
+ info.className = Button::class.java.name
282
+ info.hintText = "Double tap to dismiss."
283
+ }
284
+ })
285
+ addView(handleBar)
286
+ }
264
287
 
265
288
  // Container
266
- val halfExpandedRatio = snapPoints.firstOrNull()?.toFloat() ?: 0.55f
289
+ val halfExpandedRatio = (snapPoints.firstOrNull()?.toFloat() ?: 0.5f).coerceIn(0.05f, 1f)
290
+ val expandedRatio = maxOf(
291
+ halfExpandedRatio,
292
+ (snapPoints.getOrNull(1)?.toFloat() ?: 1f).coerceIn(0.05f, 1f)
293
+ )
267
294
  val minSheetHeight = (activity.resources.displayMetrics.heightPixels * halfExpandedRatio).toInt()
268
295
  val cornerRadius = 16 * density
269
296
  val container = LinearLayout(activity).apply {
@@ -279,7 +306,7 @@ class EmojiSheetModule : Module() {
279
306
  }
280
307
  clipToOutline = true
281
308
  outlineProvider = android.view.ViewOutlineProvider.BACKGROUND
282
- addView(handleBar)
309
+ addView(handleTouchTarget)
283
310
  val pickerLp = LinearLayout.LayoutParams(
284
311
  LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
285
312
  )
@@ -288,16 +315,41 @@ class EmojiSheetModule : Module() {
288
315
 
289
316
  bottomSheet.setContentView(container)
290
317
 
291
- ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets ->
318
+ var wasKeyboardVisible = false
319
+ val applyDialogInsets: (WindowInsetsCompat) -> Unit = { insets ->
292
320
  val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
293
321
  val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
294
- view.setPadding(
322
+ val navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
323
+ val keyboardVisible = imeInsets.bottom > navigationInsets.bottom
324
+ container.setPadding(
295
325
  systemBarInsets.left,
296
326
  0,
297
327
  systemBarInsets.right,
298
- maxOf(imeInsets.bottom, systemBarInsets.bottom)
328
+ if (keyboardVisible) 0 else systemBarInsets.bottom
329
+ )
330
+ updateSheetForKeyboard(
331
+ bottomSheet,
332
+ pickerView,
333
+ minSheetHeight,
334
+ expandedRatio,
335
+ imeInsets.bottom,
336
+ navigationInsets.bottom
299
337
  )
300
- WindowInsetsCompat.CONSUMED
338
+ if (keyboardVisible && bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
339
+ pickerView.setSheetExpansionInProgress(true)
340
+ bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED
341
+ }
342
+ val shouldRestoreInitialSnap = wasKeyboardVisible &&
343
+ !keyboardVisible &&
344
+ bottomSheet.behavior.state != BottomSheetBehavior.STATE_HIDDEN
345
+ if (shouldRestoreInitialSnap) {
346
+ bottomSheet.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
347
+ }
348
+ wasKeyboardVisible = keyboardVisible
349
+ }
350
+ ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
351
+ applyDialogInsets(insets)
352
+ insets
301
353
  }
302
354
 
303
355
  // Strip ALL backgrounds from BottomSheet internals
@@ -305,22 +357,37 @@ class EmojiSheetModule : Module() {
305
357
  val d = dlg as BottomSheetDialog
306
358
  val bottomSheetInternal = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
307
359
  bottomSheetContentView = bottomSheetInternal
360
+ d.findViewById<View>(com.google.android.material.R.id.container)?.fitsSystemWindows = false
361
+ d.findViewById<View>(com.google.android.material.R.id.coordinator)?.fitsSystemWindows = false
308
362
  bottomSheetInternal?.apply {
363
+ fitsSystemWindows = false
309
364
  setBackgroundColor(Color.TRANSPARENT)
310
365
  (parent as? View)?.setBackgroundColor(Color.TRANSPARENT)
366
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
367
+ applyDialogInsets(insets)
368
+ insets
369
+ }
311
370
  }
371
+ bottomSheet.window?.let { window ->
372
+ WindowCompat.setDecorFitsSystemWindows(window, false)
373
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
374
+ }
375
+ ViewCompat.requestApplyInsets(container)
376
+ bottomSheetInternal?.let { ViewCompat.requestApplyInsets(it) }
312
377
  pickerView.loadDataAsync()
313
378
  sendEvent("onSheetOpened", android.os.Bundle())
314
379
  }
315
380
 
316
381
  bottomSheet.window?.let { window ->
317
382
  WindowCompat.setDecorFitsSystemWindows(window, false)
383
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
318
384
  }
319
385
  bottomSheet.window?.setDimAmount(backdropOpacity)
320
386
 
321
387
  bottomSheet.behavior.apply {
322
388
  state = BottomSheetBehavior.STATE_HALF_EXPANDED
323
389
  this.halfExpandedRatio = halfExpandedRatio
390
+ expandedOffset = (activity.resources.displayMetrics.heightPixels * (1f - expandedRatio)).toInt()
324
391
  peekHeight = minSheetHeight
325
392
  isHideable = true
326
393
  skipCollapsed = false
@@ -328,6 +395,9 @@ class EmojiSheetModule : Module() {
328
395
  isDraggable = gestureEnabled
329
396
  addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
330
397
  override fun onStateChanged(bottomSheet: View, newState: Int) {
398
+ if (newState == BottomSheetBehavior.STATE_DRAGGING) {
399
+ dismissKeyboard()
400
+ }
331
401
  pickerView.setSheetExpanded(newState == BottomSheetBehavior.STATE_EXPANDED)
332
402
  pickerView.setSheetExpansionInProgress(
333
403
  newState == BottomSheetBehavior.STATE_DRAGGING ||
@@ -365,6 +435,64 @@ class EmojiSheetModule : Module() {
365
435
  dialog = null
366
436
  }
367
437
 
438
+ private fun dismissKeyboard() {
439
+ val activity = appContext.currentActivity ?: return
440
+ val focusedView = activity.currentFocus
441
+ val tokenView = focusedView
442
+ ?: bottomSheetContentView
443
+ ?: dialog?.window?.decorView
444
+ ?: return
445
+ val inputMethodManager = tokenView.context
446
+ .getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager
447
+ ?: return
448
+
449
+ inputMethodManager.hideSoftInputFromWindow(tokenView.windowToken, 0)
450
+ focusedView?.clearFocus()
451
+ }
452
+
453
+ private fun updateSheetForKeyboard(
454
+ bottomSheet: BottomSheetDialog,
455
+ pickerView: EmojiSheetUIView,
456
+ minSheetHeight: Int,
457
+ expandedRatio: Float,
458
+ imeBottom: Int,
459
+ navigationBottom: Int
460
+ ) {
461
+ val keyboardHeight = if (imeBottom > navigationBottom) imeBottom else 0
462
+ val keyboardVisible = keyboardHeight > 0
463
+ val sheetView = bottomSheetContentView
464
+ ?: bottomSheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
465
+
466
+ pickerView.setKeyboardDockedToSheet(keyboardVisible)
467
+
468
+ if (sheetView == null) return
469
+
470
+ val parentHeight = ((sheetView.parent as? View)?.height ?: 0)
471
+ .takeIf { it > 0 }
472
+ ?: sheetView.rootView.height.takeIf { it > 0 }
473
+ ?: sheetView.resources.displayMetrics.heightPixels
474
+ val desiredExpandedHeight = maxOf(1, (parentHeight * expandedRatio).toInt())
475
+ val targetHeight = if (keyboardVisible) {
476
+ minOf(maxOf(1, parentHeight - keyboardHeight), desiredExpandedHeight)
477
+ } else {
478
+ ViewGroup.LayoutParams.MATCH_PARENT
479
+ }
480
+ bottomSheet.behavior.expandedOffset = if (keyboardVisible) {
481
+ maxOf(0, parentHeight - keyboardHeight - targetHeight)
482
+ } else {
483
+ maxOf(0, parentHeight - desiredExpandedHeight)
484
+ }
485
+
486
+ val layoutParams = sheetView.layoutParams
487
+ if (layoutParams.height != targetHeight) {
488
+ layoutParams.height = targetHeight
489
+ sheetView.layoutParams = layoutParams
490
+ }
491
+ sheetView.translationY = 0f
492
+ bottomSheet.behavior.peekHeight = minSheetHeight
493
+ sheetView.requestLayout()
494
+ }
495
+
368
496
  private fun updateSheetDrag(bottomSheet: BottomSheetDialog, distance: Float) {
369
497
  val sheetView = bottomSheetContentView
370
498
  ?: bottomSheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
@@ -10,6 +10,8 @@ import android.view.VelocityTracker
10
10
  import android.widget.FrameLayout
11
11
  import android.widget.LinearLayout
12
12
  import android.widget.TextView
13
+ import androidx.core.view.ViewCompat
14
+ import androidx.core.view.WindowInsetsCompat
13
15
  import androidx.recyclerview.widget.GridLayoutManager
14
16
  import androidx.recyclerview.widget.RecyclerView
15
17
  import kotlinx.coroutines.CoroutineScope
@@ -23,8 +25,6 @@ import kotlinx.coroutines.launch
23
25
  import kotlinx.coroutines.sync.Mutex
24
26
  import kotlinx.coroutines.sync.withLock
25
27
  import kotlinx.coroutines.withContext
26
- import java.text.Normalizer
27
- import java.util.Locale
28
28
 
29
29
  class EmojiSheetUIView(context: Context) : LinearLayout(context) {
30
30
 
@@ -33,7 +33,6 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
33
33
  private const val FREQ_COUNT_SUFFIX = "_count"
34
34
  private const val FREQ_DAY_SUFFIX = "_day"
35
35
  private const val FREQ_TIME_SUFFIX = "_time"
36
- private val COMBINING_MARKS_REGEX = "\\p{Mn}+".toRegex()
37
36
  private val cacheScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
38
37
  private val cacheMutex = Mutex()
39
38
  @Volatile
@@ -75,14 +74,17 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
75
74
  val arr = obj.getJSONArray(key)
76
75
  val keywords = merged.getOrPut(key) { mutableListOf() }
77
76
  for (i in 0 until arr.length()) {
78
- keywords.add(arr.getString(i))
77
+ val normalizedKeyword = EmojiData.normalizeSearchText(arr.getString(i))
78
+ if (normalizedKeyword.isNotBlank()) {
79
+ keywords.add(normalizedKeyword)
80
+ }
79
81
  }
80
82
  }
81
83
  }
82
84
  } catch (e: Exception) {
83
85
  // Fallback or empty
84
86
  }
85
- return merged
87
+ return merged.mapValues { it.value.distinct() }
86
88
  }
87
89
  }
88
90
 
@@ -114,6 +116,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
114
116
  private var currentTheme = EmojiSheetTheme.light
115
117
  private var allCategories: List<EmojiCategory> = emptyList()
116
118
  private var allCategoryKeys: List<String> = emptyList()
119
+ // Values are normalized once at load time so search scoring only compares strings.
117
120
  private var localizedKeywords: Map<String, List<String>> = emptyMap()
118
121
 
119
122
  private val searchBar: EmojiSearchBar
@@ -139,6 +142,10 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
139
142
  private var velocityTracker: VelocityTracker? = null
140
143
  private var topPullStartY: Float? = null
141
144
  private val topPullActivationThresholdPx = 24f * context.resources.displayMetrics.density
145
+ private val keyboardResultBottomGapPx = (16f * context.resources.displayMetrics.density).toInt()
146
+ private var baseRecyclerBottomPadding = 0
147
+ private var keyboardRecyclerBottomPadding = 0
148
+ private var usesDockedSheetKeyboardInsets = false
142
149
  private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
143
150
  private var loadJob: Job? = null
144
151
  private var searchJob: Job? = null
@@ -238,7 +245,12 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
238
245
 
239
246
  val deltaY = e.y - initialTouchY
240
247
  val isAtTop = !rv.canScrollVertically(-1)
241
- if (!isSheetExpanded && deltaY < -touchSlop && !didTriggerExpandForCurrentDrag) {
248
+ if (
249
+ !isSheetExpanded &&
250
+ onScrollIntentUp != null &&
251
+ deltaY < -touchSlop &&
252
+ !didTriggerExpandForCurrentDrag
253
+ ) {
242
254
  didTriggerExpandForCurrentDrag = true
243
255
  isSheetExpansionInProgress = true
244
256
  rv.stopScroll()
@@ -284,7 +296,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
284
296
  }
285
297
 
286
298
  override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
287
- if (!isSheetExpanded) {
299
+ if (!isSheetExpanded && onScrollIntentUp != null) {
288
300
  rv.stopScroll()
289
301
  return
290
302
  }
@@ -330,6 +342,14 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
330
342
  }
331
343
  addView(contentFrame)
332
344
 
345
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
346
+ setKeyboardBottomInset(
347
+ imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom,
348
+ navigationBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
349
+ )
350
+ insets
351
+ }
352
+
333
353
  applyTheme(currentTheme)
334
354
  applyLayoutDirection()
335
355
  }
@@ -348,6 +368,28 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
348
368
  }
349
369
  }
350
370
 
371
+ fun setKeyboardBottomInset(imeBottom: Int, navigationBottom: Int) {
372
+ if (usesDockedSheetKeyboardInsets) return
373
+
374
+ val keyboardOnlyBottom = maxOf(0, imeBottom - navigationBottom)
375
+ setKeyboardRecyclerBottomPadding(
376
+ if (keyboardOnlyBottom > 0) keyboardOnlyBottom + keyboardResultBottomGapPx else 0
377
+ )
378
+ }
379
+
380
+ fun setKeyboardDockedToSheet(keyboardVisible: Boolean) {
381
+ usesDockedSheetKeyboardInsets = true
382
+ setKeyboardRecyclerBottomPadding(
383
+ if (keyboardVisible) keyboardResultBottomGapPx else 0
384
+ )
385
+ }
386
+
387
+ private fun setKeyboardRecyclerBottomPadding(bottomPadding: Int) {
388
+ if (keyboardRecyclerBottomPadding == bottomPadding) return
389
+ keyboardRecyclerBottomPadding = bottomPadding
390
+ updateRecyclerBottomPadding()
391
+ }
392
+
351
393
  /** Call after setting configurable properties but before loadDataAsync to apply layout changes. */
352
394
  fun applyConfiguration() {
353
395
  // Update grid adapter settings
@@ -419,18 +461,32 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
419
461
 
420
462
  // Bottom padding so grid content scrolls above the floating bar
421
463
  val totalBarSpace = stripHeight + bottomInset * 2
422
- recyclerView.setPadding(
423
- recyclerView.paddingLeft,
424
- recyclerView.paddingTop,
425
- recyclerView.paddingRight,
426
- totalBarSpace
427
- )
428
- recyclerView.clipToPadding = false
464
+ baseRecyclerBottomPadding = totalBarSpace
465
+ updateRecyclerBottomPadding()
429
466
 
430
467
  addView(wrapperFrame)
468
+ } else {
469
+ baseRecyclerBottomPadding = 0
470
+ updateRecyclerBottomPadding()
431
471
  }
432
472
  }
433
473
 
474
+ private fun setGridItems(items: List<EmojiGridAdapter.ListItem>, sectionPositions: List<Int>) {
475
+ gridAdapter.setItems(items, sectionPositions)
476
+ stickyHeaderDecoration.invalidateCache()
477
+ recyclerView.invalidateItemDecorations()
478
+ }
479
+
480
+ private fun updateRecyclerBottomPadding() {
481
+ recyclerView.setPadding(
482
+ recyclerView.paddingLeft,
483
+ recyclerView.paddingTop,
484
+ recyclerView.paddingRight,
485
+ baseRecyclerBottomPadding + keyboardRecyclerBottomPadding
486
+ )
487
+ recyclerView.clipToPadding = false
488
+ }
489
+
434
490
  var searchPlaceholder: String? = null
435
491
  set(value) { field = value; if (value != null) searchBar.setHint(value) }
436
492
  var noResultsText: String? = null
@@ -549,8 +605,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
549
605
  }
550
606
  }
551
607
 
552
- gridAdapter.setItems(items, sectionPositions)
553
- stickyHeaderDecoration.invalidateCache()
608
+ setGridItems(items, sectionPositions)
554
609
 
555
610
  // Rebuild category keys in case frequently_used changed
556
611
  val newKeys = buildCategoryKeys()
@@ -625,6 +680,10 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
625
680
  isSearchActive = true
626
681
  categoryStrip.visibility = View.GONE
627
682
  bottomPillContainer?.visibility = View.GONE
683
+ emptyStateLabel.visibility = View.GONE
684
+ recyclerView.visibility = View.VISIBLE
685
+ setGridItems(emptyList(), emptyList())
686
+ recyclerView.scrollToPosition(0)
628
687
 
629
688
  val generation = ++searchGeneration
630
689
  val categories = allCategories
@@ -633,7 +692,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
633
692
  val exclude = excludeEmojis
634
693
  searchJob = viewScope.launch {
635
694
  val matchedItems = withContext(Dispatchers.Default) {
636
- val normalizedQueryVariants = normalizedSearchVariants(trimmedQuery)
695
+ val normalizedQueryVariants = EmojiData.normalizedSearchVariants(trimmedQuery)
637
696
  val scored = mutableListOf<Pair<EmojiGridAdapter.ListItem.Emoji, Int>>()
638
697
 
639
698
  for (cat in categories) {
@@ -671,7 +730,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
671
730
 
672
731
  emptyStateLabel.visibility = if (matchedItems.isNotEmpty()) View.GONE else View.VISIBLE
673
732
  recyclerView.visibility = if (matchedItems.isNotEmpty()) View.VISIBLE else View.GONE
674
- gridAdapter.setItems(results, sectionPositions)
733
+ setGridItems(results, sectionPositions)
675
734
  recyclerView.scrollToPosition(0)
676
735
  }
677
736
  }
@@ -782,22 +841,6 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
782
841
  return emoji.filter { it.code != 0xFE0E && it.code != 0xFE0F }
783
842
  }
784
843
 
785
- private fun normalizeSearchText(text: String): String {
786
- val normalized = Normalizer.normalize(text, Normalizer.Form.NFD)
787
- .replace(COMBINING_MARKS_REGEX, "")
788
- return normalized
789
- .trim()
790
- .lowercase(Locale.ROOT)
791
- }
792
-
793
- private fun normalizedSearchVariants(text: String): Set<String> {
794
- return setOf(normalizeSearchText(text)).filter { it.isNotBlank() }.toSet()
795
- }
796
-
797
- private fun matchesSearch(normalizedText: String, queryVariants: Set<String>): Boolean {
798
- return queryVariants.any { normalizedText.contains(it) }
799
- }
800
-
801
844
  // Relevance scoring for search results:
802
845
  // 100 = exact name match, 90 = name starts with, 80 = exact keyword,
803
846
  // 70 = keyword starts with, 50 = name contains, 30 = keyword contains,
@@ -807,18 +850,15 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
807
850
  queryVariants: Set<String>,
808
851
  localizedKeywords: Map<String, List<String>>
809
852
  ): Int {
810
- val nameNorm = normalizeSearchText(emoji.name)
811
-
812
853
  // Check name
813
854
  for (variant in queryVariants) {
814
- if (nameNorm == variant) return 100
815
- if (nameNorm.startsWith(variant)) return 90
855
+ if (emoji.normalizedName == variant) return 100
856
+ if (emoji.normalizedName.startsWith(variant)) return 90
816
857
  }
817
858
 
818
859
  // Check built-in keywords
819
860
  var bestScore = 0
820
- for (kw in emoji.keywords) {
821
- val kwNorm = normalizeSearchText(kw)
861
+ for (kwNorm in emoji.normalizedKeywords) {
822
862
  for (variant in queryVariants) {
823
863
  if (kwNorm == variant) bestScore = maxOf(bestScore, 80)
824
864
  else if (kwNorm.startsWith(variant)) bestScore = maxOf(bestScore, 70)
@@ -830,7 +870,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
830
870
  // Check name contains (lower priority than keyword exact/startsWith)
831
871
  if (bestScore < 50) {
832
872
  for (variant in queryVariants) {
833
- if (nameNorm.contains(variant)) bestScore = maxOf(bestScore, 50)
873
+ if (emoji.normalizedName.contains(variant)) bestScore = maxOf(bestScore, 50)
834
874
  }
835
875
  }
836
876
 
@@ -841,7 +881,7 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
841
881
  ?: localizedKeywords[stripVariationSelectors(emoji.emoji)]
842
882
  if (localKw != null) {
843
883
  for (kw in localKw) {
844
- if (queryVariants.any { normalizeSearchText(kw).contains(it) }) return 10
884
+ if (queryVariants.any { kw.contains(it) }) return 10
845
885
  }
846
886
  }
847
887
 
@@ -863,4 +903,9 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
863
903
  searchJob?.cancel()
864
904
  viewScope.cancel()
865
905
  }
906
+
907
+ override fun onAttachedToWindow() {
908
+ super.onAttachedToWindow()
909
+ ViewCompat.requestApplyInsets(this)
910
+ }
866
911
  }
@@ -120,7 +120,11 @@ class EmojiGridView: UIView, UICollectionViewDataSource, UICollectionViewDelegat
120
120
  func updateSections(_ newSections: [EmojiSection], categoryNames: [String: String]) {
121
121
  self.sections = newSections
122
122
  self.categoryNames = categoryNames
123
- collectionView.reloadData()
123
+ UIView.performWithoutAnimation {
124
+ collectionView.collectionViewLayout.invalidateLayout()
125
+ collectionView.reloadData()
126
+ collectionView.layoutIfNeeded()
127
+ }
124
128
  }
125
129
 
126
130
  func applyTheme(_ theme: EmojiSheetTheme) {
@@ -10,6 +10,9 @@ struct EmojiItem: Sendable {
10
10
  let toneEnabled: Bool
11
11
  let keywords: [String]
12
12
  let id: String
13
+ let normalizedName: String
14
+ let normalizedNameVariants: [String]
15
+ let normalizedKeywords: [String]
13
16
  }
14
17
 
15
18
  struct EmojiSection: Sendable {
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'EmojiSheetModule'
3
- s.version = '2.0.3'
3
+ s.version = '2.1.0'
4
4
  s.summary = 'Native emoji picker bottom sheet for React Native'
5
5
  s.description = 'A fully native iOS/Android emoji picker presented in a bottom sheet with search, skin tones, and theming support.'
6
6
  s.author = ''
@@ -217,7 +217,10 @@ public class EmojiSheetModule: Module {
217
217
  sheetBackgroundColor: bgColor,
218
218
  theme: customTheme
219
219
  )
220
- sheetVC.mediumDetentRatio = CGFloat(snapPoints.first ?? 0.5)
220
+ let mediumRatio = min(max(CGFloat(snapPoints.first ?? 0.5), 0.05), 1)
221
+ let largeRatio = min(max(CGFloat(snapPoints.dropFirst().first ?? 1.0), mediumRatio), 1)
222
+ sheetVC.mediumDetentRatio = mediumRatio
223
+ sheetVC.largeDetentRatio = largeRatio
221
224
  sheetVC.gestureEnabled = gestureEnabled
222
225
  sheetVC.embedPickerView(pickerView)
223
226
  sheetVC.onAppear = { [weak self] in
@@ -329,10 +332,12 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
329
332
  var onAppear: (() -> Void)?
330
333
  var onDismiss: (() -> Void)?
331
334
  var mediumDetentRatio: CGFloat = 0.5
335
+ var largeDetentRatio: CGFloat = 1.0
332
336
  var gestureEnabled: Bool = true
333
337
 
334
338
  private let backdropView = UIView()
335
339
  private let sheetContainerView = UIView()
340
+ private let grabberHitAreaView = UIControl()
336
341
  private let grabberView = UIView()
337
342
  private let contentContainerView = UIView()
338
343
  private let backdropColor: UIColor
@@ -387,7 +392,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
387
392
  options: [.curveEaseOut]
388
393
  ) {
389
394
  self.backdropView.alpha = Layout.backdropAlpha
390
- self.sheetContainerView.transform = self.transform(for: self.currentDetent)
395
+ self.applyDetentLayout(self.currentDetent)
391
396
  } completion: { [weak self] _ in
392
397
  self?.onAppear?()
393
398
  }
@@ -404,7 +409,12 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
404
409
  }
405
410
 
406
411
  guard hasPresented, !isAnimatingDismissal else { return }
407
- sheetContainerView.transform = transform(for: currentDetent)
412
+ applyDetentLayout(currentDetent)
413
+ }
414
+
415
+ override func accessibilityPerformEscape() -> Bool {
416
+ requestDismiss()
417
+ return true
408
418
  }
409
419
 
410
420
  func embedPickerView(_ embeddedView: UIView) {
@@ -454,8 +464,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
454
464
  currentDetent = detent
455
465
 
456
466
  let updates = {
457
- self.sheetContainerView.transform = self.transform(for: detent)
467
+ self.applyDetentLayout(detent)
458
468
  self.backdropView.alpha = Layout.backdropAlpha
469
+ self.view.layoutIfNeeded()
459
470
  }
460
471
 
461
472
  if animated {
@@ -497,12 +508,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
497
508
  .union(.beginFromCurrentState)
498
509
 
499
510
  let overlap = keyboardOverlap(for: endFrame)
511
+ let wasKeyboardVisible = keyboardOverlap > 0
500
512
  let targetDetent: Detent
501
513
 
502
- if overlap > 0, isSearchFocused {
514
+ if wasKeyboardVisible, overlap == 0 {
515
+ targetDetent = .medium
516
+ } else if overlap > 0, isSearchFocused {
503
517
  targetDetent = .large
504
- } else if overlap == 0, !isSearchFocused, let previousDetent = detentBeforeSearchFocus {
505
- targetDetent = previousDetent
506
518
  } else {
507
519
  targetDetent = currentDetent
508
520
  }
@@ -513,13 +525,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
513
525
  options: animationOptions
514
526
  ) {
515
527
  self.keyboardOverlap = overlap
516
- self.sheetBottomConstraint?.constant = -overlap
517
- self.sheetContainerView.transform = self.transform(for: targetDetent)
528
+ self.applyDetentLayout(targetDetent, keyboardOverlap: overlap)
518
529
  self.backdropView.alpha = Layout.backdropAlpha
519
530
  self.view.layoutIfNeeded()
520
531
  } completion: { _ in
521
532
  self.currentDetent = targetDetent
522
- if overlap == 0, !self.isSearchFocused {
533
+ if overlap == 0 {
534
+ self.isSearchFocused = false
523
535
  self.detentBeforeSearchFocus = nil
524
536
  }
525
537
  }
@@ -552,6 +564,8 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
552
564
 
553
565
  private func updateSheetDrag(_ distance: CGFloat) {
554
566
  guard !isAnimatingDismissal, currentDetent == .large else { return }
567
+ dismissKeyboardForSheetDrag()
568
+
555
569
  let adjustedDistance = max(0, distance * 0.7)
556
570
  sheetContainerView.layer.removeAllAnimations()
557
571
  sheetContainerView.transform = CGAffineTransform(translationX: 0, y: adjustedDistance)
@@ -566,9 +580,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
566
580
  let dismissalThreshold = max(sheetContainerView.bounds.height * 0.5, 1)
567
581
  let shouldDismiss = distance >= dismissalThreshold || velocity >= 1400
568
582
  if shouldDismiss {
569
- dismissSheet { [weak self] in
570
- self?.onDismiss?()
571
- }
583
+ requestDismiss()
572
584
  return
573
585
  }
574
586
 
@@ -579,8 +591,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
579
591
  initialSpringVelocity: 0.1,
580
592
  options: [.curveEaseOut]
581
593
  ) {
582
- self.sheetContainerView.transform = self.transform(for: .large)
594
+ self.applyDetentLayout(.large)
583
595
  self.backdropView.alpha = Layout.backdropAlpha
596
+ self.view.layoutIfNeeded()
584
597
  }
585
598
  }
586
599
 
@@ -623,17 +636,28 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
623
636
  sheetContainerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
624
637
  sheetContainerView.layer.masksToBounds = true
625
638
 
639
+ grabberHitAreaView.translatesAutoresizingMaskIntoConstraints = false
640
+ grabberHitAreaView.backgroundColor = .clear
641
+ grabberHitAreaView.isAccessibilityElement = true
642
+ grabberHitAreaView.accessibilityLabel = "Dismiss emoji sheet"
643
+ grabberHitAreaView.accessibilityHint = "Double-tap to dismiss."
644
+ grabberHitAreaView.accessibilityTraits = .button
645
+ grabberHitAreaView.addTarget(self, action: #selector(handleGrabberTap), for: .touchUpInside)
646
+
626
647
  grabberView.translatesAutoresizingMaskIntoConstraints = false
627
648
  grabberView.backgroundColor = theme.handleColor
628
649
  grabberView.layer.cornerRadius = Layout.grabberHeight / 2
650
+ grabberView.isUserInteractionEnabled = false
629
651
 
630
652
  contentContainerView.translatesAutoresizingMaskIntoConstraints = false
631
653
  contentContainerView.backgroundColor = .clear
632
654
 
655
+ sheetContainerView.accessibilityViewIsModal = true
633
656
  view.addSubview(backdropView)
634
657
  view.addSubview(sheetContainerView)
635
658
  sheetContainerView.addSubview(contentContainerView)
636
- sheetContainerView.addSubview(grabberView)
659
+ sheetContainerView.addSubview(grabberHitAreaView)
660
+ grabberHitAreaView.addSubview(grabberView)
637
661
 
638
662
  if gestureEnabled {
639
663
  let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleSheetPan(_:)))
@@ -659,11 +683,16 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
659
683
  sheetContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
660
684
  sheetBottomConstraint,
661
685
 
686
+ grabberHitAreaView.topAnchor.constraint(equalTo: sheetContainerView.topAnchor),
687
+ grabberHitAreaView.centerXAnchor.constraint(equalTo: sheetContainerView.centerXAnchor),
688
+ grabberHitAreaView.widthAnchor.constraint(equalToConstant: 96),
689
+ grabberHitAreaView.heightAnchor.constraint(equalToConstant: 24),
690
+
662
691
  grabberView.topAnchor.constraint(
663
- equalTo: sheetContainerView.topAnchor,
692
+ equalTo: grabberHitAreaView.topAnchor,
664
693
  constant: Layout.grabberTopInset
665
694
  ),
666
- grabberView.centerXAnchor.constraint(equalTo: sheetContainerView.centerXAnchor),
695
+ grabberView.centerXAnchor.constraint(equalTo: grabberHitAreaView.centerXAnchor),
667
696
  grabberView.widthAnchor.constraint(equalToConstant: Layout.grabberWidth),
668
697
  grabberView.heightAnchor.constraint(equalToConstant: Layout.grabberHeight),
669
698
 
@@ -675,6 +704,15 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
675
704
  }
676
705
 
677
706
  @objc private func handleBackdropTap() {
707
+ requestDismiss()
708
+ }
709
+
710
+ @objc private func handleGrabberTap() {
711
+ requestDismiss()
712
+ }
713
+
714
+ private func requestDismiss() {
715
+ guard !isAnimatingDismissal else { return }
678
716
  dismissSheet { [weak self] in
679
717
  self?.onDismiss?()
680
718
  }
@@ -692,6 +730,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
692
730
 
693
731
  switch gesture.state {
694
732
  case .changed:
733
+ if gesture.translation(in: view).y > 0 {
734
+ dismissKeyboardForSheetDrag()
735
+ }
695
736
  sheetContainerView.transform = CGAffineTransform(translationX: 0, y: translationY)
696
737
  backdropView.alpha = Layout.backdropAlpha * (1 - dismissProgress)
697
738
  case .ended, .cancelled:
@@ -701,9 +742,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
701
742
  velocityY > Layout.dismissVelocityThreshold
702
743
 
703
744
  if shouldDismiss {
704
- dismissSheet { [weak self] in
705
- self?.onDismiss?()
706
- }
745
+ requestDismiss()
707
746
  } else {
708
747
  let targetDetent: Detent
709
748
 
@@ -727,29 +766,61 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
727
766
  }
728
767
  }
729
768
 
769
+ private func dismissKeyboardForSheetDrag() {
770
+ view.endEditing(true)
771
+ }
772
+
730
773
  private var dismissalTranslation: CGFloat {
731
774
  sheetContainerView.bounds.height + view.safeAreaInsets.bottom + 24
732
775
  }
733
776
 
734
- private func transform(for detent: Detent) -> CGAffineTransform {
735
- CGAffineTransform(translationX: 0, y: translation(for: detent))
777
+ private func applyDetentLayout(_ detent: Detent, keyboardOverlap overlap: CGFloat? = nil) {
778
+ let overlap = overlap ?? keyboardOverlap
779
+ sheetBottomConstraint?.constant = bottomConstraintConstant(for: detent, keyboardOverlap: overlap)
780
+ sheetContainerView.transform = transform(for: detent, keyboardOverlap: overlap)
781
+ }
782
+
783
+ private func transform(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGAffineTransform {
784
+ CGAffineTransform(translationX: 0, y: translation(for: detent, keyboardOverlap: overlap))
736
785
  }
737
786
 
738
787
  private func transformForDismissal() -> CGAffineTransform {
739
788
  CGAffineTransform(translationX: 0, y: dismissalTranslation)
740
789
  }
741
790
 
742
- private func translation(for detent: Detent) -> CGFloat {
791
+ private func bottomConstraintConstant(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
792
+ let overlap = overlap ?? keyboardOverlap
793
+ guard overlap > 0 else { return 0 }
794
+ return -overlap - translation(for: detent, keyboardOverlap: overlap)
795
+ }
796
+
797
+ private func translation(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
798
+ let fullHeight = max(0, view.bounds.height - view.safeAreaInsets.top - Layout.sheetTopInset)
799
+ let availableHeight = max(0, fullHeight - (overlap ?? keyboardOverlap))
800
+ let visibleHeight = visibleHeight(for: detent, availableHeight: availableHeight, fullHeight: fullHeight)
801
+ return max(0, availableHeight - visibleHeight)
802
+ }
803
+
804
+ private func visibleHeight(for detent: Detent, availableHeight: CGFloat, fullHeight: CGFloat) -> CGFloat {
743
805
  switch detent {
744
806
  case .large:
745
- return 0
746
- case .medium:
747
- let containerHeight = max(sheetContainerView.bounds.height, 0)
748
- let visibleHeight = max(
807
+ let mediumVisibleHeight = max(
749
808
  Layout.minimumMediumVisibleHeight,
750
- containerHeight * mediumDetentRatio
809
+ fullHeight * mediumDetentRatio
810
+ )
811
+ return min(
812
+ availableHeight,
813
+ max(mediumVisibleHeight, fullHeight * largeDetentRatio)
814
+ )
815
+ case .medium:
816
+ let visibleHeight = min(
817
+ availableHeight,
818
+ max(
819
+ Layout.minimumMediumVisibleHeight,
820
+ fullHeight * mediumDetentRatio
821
+ )
751
822
  )
752
- return max(0, containerHeight - min(visibleHeight, containerHeight))
823
+ return max(0, visibleHeight)
753
824
  }
754
825
  }
755
826
  }
@@ -91,6 +91,7 @@ class EmojiSheetUIView: UIView,
91
91
  static let floatingBarHorizontalInset: CGFloat = 16
92
92
  static let floatingBarBottomInset: CGFloat = 8
93
93
  static let floatingBarCornerRadius: CGFloat = 22
94
+ static let keyboardBottomSpacing: CGFloat = 16
94
95
  }
95
96
 
96
97
  weak var delegate: EmojiSheetUIViewDelegate?
@@ -141,6 +142,7 @@ class EmojiSheetUIView: UIView,
141
142
  private var allSections: [EmojiSection] = []
142
143
  private var filteredSections: [EmojiSection] = []
143
144
  private var frequentlyUsedSection: EmojiSection?
145
+ // Values are normalized once at load time so search scoring only compares strings.
144
146
  private var localizedKeywords: [String: [String]] = [:]
145
147
  private var currentSearchText: String?
146
148
  private var loadTask: Task<Void, Never>?
@@ -148,6 +150,8 @@ class EmojiSheetUIView: UIView,
148
150
  // Search work is cancellable. The generation counter remains as a lightweight
149
151
  // secondary guard so stale results never apply after a newer query wins.
150
152
  private var searchGeneration: Int = 0
153
+ private var baseGridBottomInset: CGFloat = 0
154
+ private var keyboardGridBottomInset: CGFloat = 0
151
155
 
152
156
  private let searchBar = EmojiSearchBar()
153
157
  private let categoryStrip = EmojiCategoryStrip()
@@ -181,6 +185,7 @@ class EmojiSheetUIView: UIView,
181
185
  "symbols": "Symbols",
182
186
  "flags": "Flags",
183
187
  "frequently_used": "Frequently Used",
188
+ "search_results": "Search Results",
184
189
  ]
185
190
 
186
191
  // MARK: - Init
@@ -197,6 +202,7 @@ class EmojiSheetUIView: UIView,
197
202
  deinit {
198
203
  loadTask?.cancel()
199
204
  searchTask?.cancel()
205
+ NotificationCenter.default.removeObserver(self)
200
206
  }
201
207
 
202
208
  // MARK: - Setup
@@ -221,6 +227,7 @@ class EmojiSheetUIView: UIView,
221
227
  categoryStrip.delegate = self
222
228
  gridView.delegate = self
223
229
 
230
+ registerKeyboardNotifications()
224
231
  configureLayout()
225
232
  applyLayoutDirection()
226
233
  }
@@ -353,7 +360,8 @@ class EmojiSheetUIView: UIView,
353
360
  // Bottom content inset so grid content scrolls above the floating bar
354
361
  let floatingBarTotalHeight = LayoutConstants.categoryStripHeight
355
362
  + LayoutConstants.floatingBarBottomInset * 2
356
- gridView.setBottomContentInset(floatingBarTotalHeight)
363
+ baseGridBottomInset = floatingBarTotalHeight
364
+ applyGridBottomInset()
357
365
 
358
366
  } else {
359
367
  // Top bar mode (default)
@@ -364,7 +372,8 @@ class EmojiSheetUIView: UIView,
364
372
  categoryStrip.translatesAutoresizingMaskIntoConstraints = false
365
373
  }
366
374
 
367
- gridView.setBottomContentInset(0)
375
+ baseGridBottomInset = 0
376
+ applyGridBottomInset()
368
377
 
369
378
  searchBarTopConstraint = searchBar.topAnchor.constraint(
370
379
  equalTo: topAnchor, constant: LayoutConstants.searchBarTopSpacing)
@@ -399,6 +408,58 @@ class EmojiSheetUIView: UIView,
399
408
  isLayoutConfigured = true
400
409
  }
401
410
 
411
+ private func registerKeyboardNotifications() {
412
+ NotificationCenter.default.addObserver(
413
+ self,
414
+ selector: #selector(keyboardWillChangeFrame(_:)),
415
+ name: UIResponder.keyboardWillChangeFrameNotification,
416
+ object: nil
417
+ )
418
+ NotificationCenter.default.addObserver(
419
+ self,
420
+ selector: #selector(keyboardWillHide(_:)),
421
+ name: UIResponder.keyboardWillHideNotification,
422
+ object: nil
423
+ )
424
+ }
425
+
426
+ @objc private func keyboardWillChangeFrame(_ notification: Notification) {
427
+ guard
428
+ window != nil,
429
+ let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
430
+ else { return }
431
+
432
+ let keyboardFrameInView = convert(keyboardFrame, from: nil)
433
+ let overlap = bounds.intersection(keyboardFrameInView).height
434
+ keyboardGridBottomInset = overlap > 0
435
+ ? max(0, overlap - safeAreaInsets.bottom) + LayoutConstants.keyboardBottomSpacing
436
+ : 0
437
+ applyGridBottomInset(animatedWith: notification)
438
+ }
439
+
440
+ @objc private func keyboardWillHide(_ notification: Notification) {
441
+ keyboardGridBottomInset = 0
442
+ applyGridBottomInset(animatedWith: notification)
443
+ }
444
+
445
+ private func applyGridBottomInset(animatedWith notification: Notification? = nil) {
446
+ let inset = baseGridBottomInset + keyboardGridBottomInset
447
+ guard
448
+ let notification,
449
+ let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
450
+ let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
451
+ else {
452
+ gridView.setBottomContentInset(inset)
453
+ return
454
+ }
455
+
456
+ let options = UIView.AnimationOptions(rawValue: UInt(curve << 16)).union(.beginFromCurrentState)
457
+ UIView.animate(withDuration: duration, delay: 0, options: options) {
458
+ self.gridView.setBottomContentInset(inset)
459
+ self.layoutIfNeeded()
460
+ }
461
+ }
462
+
402
463
  // MARK: - Data Loading (cached across instances)
403
464
 
404
465
  private static let dataCache = EmojiDataCache.shared
@@ -443,7 +504,7 @@ class EmojiSheetUIView: UIView,
443
504
  }
444
505
  }
445
506
 
446
- return merged
507
+ return merged.mapValues { Array(Set($0)) }
447
508
  }
448
509
 
449
510
  nonisolated private static func mergeKeywords(from url: URL, into merged: inout [String: [String]]) {
@@ -451,11 +512,14 @@ class EmojiSheetUIView: UIView,
451
512
  let dict = try? JSONSerialization.jsonObject(with: data) as? [String: [String]]
452
513
  else { return }
453
514
  for (key, value) in dict {
515
+ let normalized = normalizedSearchKeywords(value)
516
+ guard !normalized.isEmpty else { continue }
517
+
454
518
  if var existing = merged[key] {
455
- existing.append(contentsOf: value)
519
+ existing.append(contentsOf: normalized)
456
520
  merged[key] = existing
457
521
  } else {
458
- merged[key] = value
522
+ merged[key] = normalized
459
523
  }
460
524
  }
461
525
  }
@@ -525,7 +589,17 @@ class EmojiSheetUIView: UIView,
525
589
  }
526
590
  let emojiVersion = Double(v) ?? 0
527
591
  guard emojiVersion <= maxVersion else { return nil }
528
- return EmojiItem(emoji: emoji, name: name, version: v, toneEnabled: toneEnabled, keywords: keywords, id: id)
592
+ return EmojiItem(
593
+ emoji: emoji,
594
+ name: name,
595
+ version: v,
596
+ toneEnabled: toneEnabled,
597
+ keywords: keywords,
598
+ id: id,
599
+ normalizedName: normalizeSearchText(name),
600
+ normalizedNameVariants: normalizedSearchVariants(name),
601
+ normalizedKeywords: normalizedSearchKeywords(keywords)
602
+ )
529
603
  }
530
604
  return EmojiSection(title: title, data: emojis)
531
605
  }
@@ -576,6 +650,12 @@ class EmojiSheetUIView: UIView,
576
650
  }
577
651
 
578
652
  categoryStrip.setSearchActive(true)
653
+ emptyStateLabel.isHidden = true
654
+ gridView.isHidden = false
655
+ filteredSections = []
656
+ gridView.updateSections([], categoryNames: mergedCategoryNames)
657
+ gridView.scrollToTop()
658
+
579
659
  searchGeneration += 1
580
660
  let generation = searchGeneration
581
661
  let sections = filteredAllSections
@@ -635,18 +715,15 @@ class EmojiSheetUIView: UIView,
635
715
  searchVariants: [String],
636
716
  localizedKeywords: [String: [String]]
637
717
  ) -> Int {
638
- let nameNorm = normalizeSearchText(item.name)
639
-
640
718
  // Check name
641
719
  for variant in searchVariants {
642
- if nameNorm == variant { return 100 }
643
- if nameNorm.hasPrefix(variant) { return 90 }
720
+ if item.normalizedName == variant { return 100 }
721
+ if item.normalizedName.hasPrefix(variant) { return 90 }
644
722
  }
645
723
 
646
724
  // Check built-in keywords
647
725
  var bestScore = 0
648
- for kw in item.keywords {
649
- let kwNorm = normalizeSearchText(kw)
726
+ for kwNorm in item.normalizedKeywords {
650
727
  for variant in searchVariants {
651
728
  if kwNorm == variant { bestScore = max(bestScore, 80) }
652
729
  else if kwNorm.hasPrefix(variant) { bestScore = max(bestScore, 70) }
@@ -658,7 +735,7 @@ class EmojiSheetUIView: UIView,
658
735
  // Check name contains (lower priority than keyword exact/startsWith)
659
736
  if bestScore < 50 {
660
737
  for variant in searchVariants {
661
- if nameNorm.contains(variant) { bestScore = max(bestScore, 50) }
738
+ if item.normalizedName.contains(variant) { bestScore = max(bestScore, 50) }
662
739
  }
663
740
  }
664
741
 
@@ -666,14 +743,12 @@ class EmojiSheetUIView: UIView,
666
743
 
667
744
  // Check localized keywords
668
745
  let localKw = Self.localizedKeywordsForEmoji(item.emoji, in: localizedKeywords)
669
- for kw in localKw {
670
- let kwNorm = normalizeSearchText(kw)
746
+ for kwNorm in localKw {
671
747
  if searchVariants.contains(where: { kwNorm.contains($0) }) { return 10 }
672
748
  }
673
749
 
674
750
  // Transliteration fallback for non-Latin scripts
675
- let nameVariants = normalizedSearchVariants(item.name)
676
- for nameVariant in nameVariants where nameVariant != nameNorm {
751
+ for nameVariant in item.normalizedNameVariants where nameVariant != item.normalizedName {
677
752
  if searchVariants.contains(where: { nameVariant.contains($0) }) { return 5 }
678
753
  }
679
754
 
@@ -758,6 +833,16 @@ class EmojiSheetUIView: UIView,
758
833
  )
759
834
  }
760
835
 
836
+ nonisolated private static func normalizedSearchKeywords(_ keywords: [String]) -> [String] {
837
+ Array(
838
+ Set(
839
+ keywords
840
+ .map(normalizeSearchText)
841
+ .filter { !$0.isEmpty }
842
+ )
843
+ )
844
+ }
845
+
761
846
  // MARK: - Theme
762
847
 
763
848
  func updateTheme(_ theme: String) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-native-sheet-emojis",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "A fully native emoji picker bottom sheet for React Native. Built with Swift and Kotlin for maximum performance. Features search with multilingual keywords, skin tones, frequently used tracking, theming, and configurable layout.",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",