expo-native-sheet-emojis 2.0.3 → 2.0.4

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,6 +8,7 @@ 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
11
12
  import androidx.core.view.ViewCompat
12
13
  import androidx.core.view.WindowCompat
13
14
  import androidx.core.view.WindowInsetsCompat
@@ -241,6 +242,7 @@ class EmojiSheetModule : Module() {
241
242
  }
242
243
  }
243
244
  pickerView.onPullDownAtTopDrag = { distance ->
245
+ dismissKeyboard()
244
246
  updateSheetDrag(bottomSheet, distance)
245
247
  }
246
248
  pickerView.onPullDownAtTopRelease = { distance, velocity ->
@@ -263,7 +265,11 @@ class EmojiSheetModule : Module() {
263
265
  }
264
266
 
265
267
  // Container
266
- val halfExpandedRatio = snapPoints.firstOrNull()?.toFloat() ?: 0.55f
268
+ val halfExpandedRatio = (snapPoints.firstOrNull()?.toFloat() ?: 0.5f).coerceIn(0.05f, 1f)
269
+ val expandedRatio = maxOf(
270
+ halfExpandedRatio,
271
+ (snapPoints.getOrNull(1)?.toFloat() ?: 1f).coerceIn(0.05f, 1f)
272
+ )
267
273
  val minSheetHeight = (activity.resources.displayMetrics.heightPixels * halfExpandedRatio).toInt()
268
274
  val cornerRadius = 16 * density
269
275
  val container = LinearLayout(activity).apply {
@@ -288,16 +294,41 @@ class EmojiSheetModule : Module() {
288
294
 
289
295
  bottomSheet.setContentView(container)
290
296
 
291
- ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets ->
297
+ var wasKeyboardVisible = false
298
+ val applyDialogInsets: (WindowInsetsCompat) -> Unit = { insets ->
292
299
  val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
293
300
  val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
294
- view.setPadding(
301
+ val navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
302
+ val keyboardVisible = imeInsets.bottom > navigationInsets.bottom
303
+ container.setPadding(
295
304
  systemBarInsets.left,
296
305
  0,
297
306
  systemBarInsets.right,
298
- maxOf(imeInsets.bottom, systemBarInsets.bottom)
307
+ if (keyboardVisible) 0 else systemBarInsets.bottom
308
+ )
309
+ updateSheetForKeyboard(
310
+ bottomSheet,
311
+ pickerView,
312
+ minSheetHeight,
313
+ expandedRatio,
314
+ imeInsets.bottom,
315
+ navigationInsets.bottom
299
316
  )
300
- WindowInsetsCompat.CONSUMED
317
+ if (keyboardVisible && bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
318
+ pickerView.setSheetExpansionInProgress(true)
319
+ bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED
320
+ }
321
+ val shouldRestoreInitialSnap = wasKeyboardVisible &&
322
+ !keyboardVisible &&
323
+ bottomSheet.behavior.state != BottomSheetBehavior.STATE_HIDDEN
324
+ if (shouldRestoreInitialSnap) {
325
+ bottomSheet.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
326
+ }
327
+ wasKeyboardVisible = keyboardVisible
328
+ }
329
+ ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
330
+ applyDialogInsets(insets)
331
+ insets
301
332
  }
302
333
 
303
334
  // Strip ALL backgrounds from BottomSheet internals
@@ -305,22 +336,37 @@ class EmojiSheetModule : Module() {
305
336
  val d = dlg as BottomSheetDialog
306
337
  val bottomSheetInternal = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
307
338
  bottomSheetContentView = bottomSheetInternal
339
+ d.findViewById<View>(com.google.android.material.R.id.container)?.fitsSystemWindows = false
340
+ d.findViewById<View>(com.google.android.material.R.id.coordinator)?.fitsSystemWindows = false
308
341
  bottomSheetInternal?.apply {
342
+ fitsSystemWindows = false
309
343
  setBackgroundColor(Color.TRANSPARENT)
310
344
  (parent as? View)?.setBackgroundColor(Color.TRANSPARENT)
345
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
346
+ applyDialogInsets(insets)
347
+ insets
348
+ }
311
349
  }
350
+ bottomSheet.window?.let { window ->
351
+ WindowCompat.setDecorFitsSystemWindows(window, false)
352
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
353
+ }
354
+ ViewCompat.requestApplyInsets(container)
355
+ bottomSheetInternal?.let { ViewCompat.requestApplyInsets(it) }
312
356
  pickerView.loadDataAsync()
313
357
  sendEvent("onSheetOpened", android.os.Bundle())
314
358
  }
315
359
 
316
360
  bottomSheet.window?.let { window ->
317
361
  WindowCompat.setDecorFitsSystemWindows(window, false)
362
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
318
363
  }
319
364
  bottomSheet.window?.setDimAmount(backdropOpacity)
320
365
 
321
366
  bottomSheet.behavior.apply {
322
367
  state = BottomSheetBehavior.STATE_HALF_EXPANDED
323
368
  this.halfExpandedRatio = halfExpandedRatio
369
+ expandedOffset = (activity.resources.displayMetrics.heightPixels * (1f - expandedRatio)).toInt()
324
370
  peekHeight = minSheetHeight
325
371
  isHideable = true
326
372
  skipCollapsed = false
@@ -328,6 +374,9 @@ class EmojiSheetModule : Module() {
328
374
  isDraggable = gestureEnabled
329
375
  addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
330
376
  override fun onStateChanged(bottomSheet: View, newState: Int) {
377
+ if (newState == BottomSheetBehavior.STATE_DRAGGING) {
378
+ dismissKeyboard()
379
+ }
331
380
  pickerView.setSheetExpanded(newState == BottomSheetBehavior.STATE_EXPANDED)
332
381
  pickerView.setSheetExpansionInProgress(
333
382
  newState == BottomSheetBehavior.STATE_DRAGGING ||
@@ -365,6 +414,64 @@ class EmojiSheetModule : Module() {
365
414
  dialog = null
366
415
  }
367
416
 
417
+ private fun dismissKeyboard() {
418
+ val activity = appContext.currentActivity ?: return
419
+ val focusedView = activity.currentFocus
420
+ val tokenView = focusedView
421
+ ?: bottomSheetContentView
422
+ ?: dialog?.window?.decorView
423
+ ?: return
424
+ val inputMethodManager = tokenView.context
425
+ .getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager
426
+ ?: return
427
+
428
+ inputMethodManager.hideSoftInputFromWindow(tokenView.windowToken, 0)
429
+ focusedView?.clearFocus()
430
+ }
431
+
432
+ private fun updateSheetForKeyboard(
433
+ bottomSheet: BottomSheetDialog,
434
+ pickerView: EmojiSheetUIView,
435
+ minSheetHeight: Int,
436
+ expandedRatio: Float,
437
+ imeBottom: Int,
438
+ navigationBottom: Int
439
+ ) {
440
+ val keyboardHeight = if (imeBottom > navigationBottom) imeBottom else 0
441
+ val keyboardVisible = keyboardHeight > 0
442
+ val sheetView = bottomSheetContentView
443
+ ?: bottomSheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
444
+
445
+ pickerView.setKeyboardDockedToSheet(keyboardVisible)
446
+
447
+ if (sheetView == null) return
448
+
449
+ val parentHeight = ((sheetView.parent as? View)?.height ?: 0)
450
+ .takeIf { it > 0 }
451
+ ?: sheetView.rootView.height.takeIf { it > 0 }
452
+ ?: sheetView.resources.displayMetrics.heightPixels
453
+ val desiredExpandedHeight = maxOf(1, (parentHeight * expandedRatio).toInt())
454
+ val targetHeight = if (keyboardVisible) {
455
+ minOf(maxOf(1, parentHeight - keyboardHeight), desiredExpandedHeight)
456
+ } else {
457
+ ViewGroup.LayoutParams.MATCH_PARENT
458
+ }
459
+ bottomSheet.behavior.expandedOffset = if (keyboardVisible) {
460
+ maxOf(0, parentHeight - keyboardHeight - targetHeight)
461
+ } else {
462
+ maxOf(0, parentHeight - desiredExpandedHeight)
463
+ }
464
+
465
+ val layoutParams = sheetView.layoutParams
466
+ if (layoutParams.height != targetHeight) {
467
+ layoutParams.height = targetHeight
468
+ sheetView.layoutParams = layoutParams
469
+ }
470
+ sheetView.translationY = 0f
471
+ bottomSheet.behavior.peekHeight = minSheetHeight
472
+ sheetView.requestLayout()
473
+ }
474
+
368
475
  private fun updateSheetDrag(bottomSheet: BottomSheetDialog, distance: Float) {
369
476
  val sheetView = bottomSheetContentView
370
477
  ?: 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.0.4'
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,6 +332,7 @@ 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()
@@ -387,7 +391,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
387
391
  options: [.curveEaseOut]
388
392
  ) {
389
393
  self.backdropView.alpha = Layout.backdropAlpha
390
- self.sheetContainerView.transform = self.transform(for: self.currentDetent)
394
+ self.applyDetentLayout(self.currentDetent)
391
395
  } completion: { [weak self] _ in
392
396
  self?.onAppear?()
393
397
  }
@@ -404,7 +408,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
404
408
  }
405
409
 
406
410
  guard hasPresented, !isAnimatingDismissal else { return }
407
- sheetContainerView.transform = transform(for: currentDetent)
411
+ applyDetentLayout(currentDetent)
408
412
  }
409
413
 
410
414
  func embedPickerView(_ embeddedView: UIView) {
@@ -454,8 +458,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
454
458
  currentDetent = detent
455
459
 
456
460
  let updates = {
457
- self.sheetContainerView.transform = self.transform(for: detent)
461
+ self.applyDetentLayout(detent)
458
462
  self.backdropView.alpha = Layout.backdropAlpha
463
+ self.view.layoutIfNeeded()
459
464
  }
460
465
 
461
466
  if animated {
@@ -497,12 +502,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
497
502
  .union(.beginFromCurrentState)
498
503
 
499
504
  let overlap = keyboardOverlap(for: endFrame)
505
+ let wasKeyboardVisible = keyboardOverlap > 0
500
506
  let targetDetent: Detent
501
507
 
502
- if overlap > 0, isSearchFocused {
508
+ if wasKeyboardVisible, overlap == 0 {
509
+ targetDetent = .medium
510
+ } else if overlap > 0, isSearchFocused {
503
511
  targetDetent = .large
504
- } else if overlap == 0, !isSearchFocused, let previousDetent = detentBeforeSearchFocus {
505
- targetDetent = previousDetent
506
512
  } else {
507
513
  targetDetent = currentDetent
508
514
  }
@@ -513,13 +519,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
513
519
  options: animationOptions
514
520
  ) {
515
521
  self.keyboardOverlap = overlap
516
- self.sheetBottomConstraint?.constant = -overlap
517
- self.sheetContainerView.transform = self.transform(for: targetDetent)
522
+ self.applyDetentLayout(targetDetent, keyboardOverlap: overlap)
518
523
  self.backdropView.alpha = Layout.backdropAlpha
519
524
  self.view.layoutIfNeeded()
520
525
  } completion: { _ in
521
526
  self.currentDetent = targetDetent
522
- if overlap == 0, !self.isSearchFocused {
527
+ if overlap == 0 {
528
+ self.isSearchFocused = false
523
529
  self.detentBeforeSearchFocus = nil
524
530
  }
525
531
  }
@@ -552,6 +558,8 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
552
558
 
553
559
  private func updateSheetDrag(_ distance: CGFloat) {
554
560
  guard !isAnimatingDismissal, currentDetent == .large else { return }
561
+ dismissKeyboardForSheetDrag()
562
+
555
563
  let adjustedDistance = max(0, distance * 0.7)
556
564
  sheetContainerView.layer.removeAllAnimations()
557
565
  sheetContainerView.transform = CGAffineTransform(translationX: 0, y: adjustedDistance)
@@ -579,8 +587,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
579
587
  initialSpringVelocity: 0.1,
580
588
  options: [.curveEaseOut]
581
589
  ) {
582
- self.sheetContainerView.transform = self.transform(for: .large)
590
+ self.applyDetentLayout(.large)
583
591
  self.backdropView.alpha = Layout.backdropAlpha
592
+ self.view.layoutIfNeeded()
584
593
  }
585
594
  }
586
595
 
@@ -692,6 +701,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
692
701
 
693
702
  switch gesture.state {
694
703
  case .changed:
704
+ if gesture.translation(in: view).y > 0 {
705
+ dismissKeyboardForSheetDrag()
706
+ }
695
707
  sheetContainerView.transform = CGAffineTransform(translationX: 0, y: translationY)
696
708
  backdropView.alpha = Layout.backdropAlpha * (1 - dismissProgress)
697
709
  case .ended, .cancelled:
@@ -727,29 +739,61 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
727
739
  }
728
740
  }
729
741
 
742
+ private func dismissKeyboardForSheetDrag() {
743
+ view.endEditing(true)
744
+ }
745
+
730
746
  private var dismissalTranslation: CGFloat {
731
747
  sheetContainerView.bounds.height + view.safeAreaInsets.bottom + 24
732
748
  }
733
749
 
734
- private func transform(for detent: Detent) -> CGAffineTransform {
735
- CGAffineTransform(translationX: 0, y: translation(for: detent))
750
+ private func applyDetentLayout(_ detent: Detent, keyboardOverlap overlap: CGFloat? = nil) {
751
+ let overlap = overlap ?? keyboardOverlap
752
+ sheetBottomConstraint?.constant = bottomConstraintConstant(for: detent, keyboardOverlap: overlap)
753
+ sheetContainerView.transform = transform(for: detent, keyboardOverlap: overlap)
754
+ }
755
+
756
+ private func transform(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGAffineTransform {
757
+ CGAffineTransform(translationX: 0, y: translation(for: detent, keyboardOverlap: overlap))
736
758
  }
737
759
 
738
760
  private func transformForDismissal() -> CGAffineTransform {
739
761
  CGAffineTransform(translationX: 0, y: dismissalTranslation)
740
762
  }
741
763
 
742
- private func translation(for detent: Detent) -> CGFloat {
764
+ private func bottomConstraintConstant(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
765
+ let overlap = overlap ?? keyboardOverlap
766
+ guard overlap > 0 else { return 0 }
767
+ return -overlap - translation(for: detent, keyboardOverlap: overlap)
768
+ }
769
+
770
+ private func translation(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
771
+ let fullHeight = max(0, view.bounds.height - view.safeAreaInsets.top - Layout.sheetTopInset)
772
+ let availableHeight = max(0, fullHeight - (overlap ?? keyboardOverlap))
773
+ let visibleHeight = visibleHeight(for: detent, availableHeight: availableHeight, fullHeight: fullHeight)
774
+ return max(0, availableHeight - visibleHeight)
775
+ }
776
+
777
+ private func visibleHeight(for detent: Detent, availableHeight: CGFloat, fullHeight: CGFloat) -> CGFloat {
743
778
  switch detent {
744
779
  case .large:
745
- return 0
746
- case .medium:
747
- let containerHeight = max(sheetContainerView.bounds.height, 0)
748
- let visibleHeight = max(
780
+ let mediumVisibleHeight = max(
749
781
  Layout.minimumMediumVisibleHeight,
750
- containerHeight * mediumDetentRatio
782
+ fullHeight * mediumDetentRatio
783
+ )
784
+ return min(
785
+ availableHeight,
786
+ max(mediumVisibleHeight, fullHeight * largeDetentRatio)
787
+ )
788
+ case .medium:
789
+ let visibleHeight = min(
790
+ availableHeight,
791
+ max(
792
+ Layout.minimumMediumVisibleHeight,
793
+ fullHeight * mediumDetentRatio
794
+ )
751
795
  )
752
- return max(0, containerHeight - min(visibleHeight, containerHeight))
796
+ return max(0, visibleHeight)
753
797
  }
754
798
  }
755
799
  }
@@ -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.0.4",
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",