expo-native-sheet-emojis 1.8.1 → 1.9.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.
@@ -47,5 +47,6 @@ android {
47
47
  implementation "com.google.android.material:material:1.12.0"
48
48
  implementation "androidx.emoji2:emoji2:1.6.0"
49
49
  implementation "androidx.emoji2:emoji2-views-helper:1.6.0"
50
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
50
51
  }
51
52
  }
@@ -113,6 +113,10 @@ class EmojiGridAdapter(
113
113
  }
114
114
  is ListItem.Emoji -> {
115
115
  val h = holder as EmojiVH
116
+ // Cancel any in-flight animation from a previous binding to prevent stale end-actions on recycled views
117
+ h.container.animate().cancel()
118
+ h.container.scaleX = 1f
119
+ h.container.scaleY = 1f
116
120
  h.textView.text = item.emoji
117
121
  h.container.contentDescription = item.name
118
122
  h.textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, emojiTextSize)
@@ -124,6 +128,7 @@ class EmojiGridAdapter(
124
128
  view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
125
129
  }
126
130
  if (enableAnimations) {
131
+ view.animate().cancel()
127
132
  view.animate().scaleX(0.85f).scaleY(0.85f).setDuration(80).withEndAction {
128
133
  view.animate().scaleX(1f).scaleY(1f).setDuration(80).start()
129
134
  }.start()
@@ -12,11 +12,19 @@ import android.widget.LinearLayout
12
12
  import android.widget.TextView
13
13
  import androidx.recyclerview.widget.GridLayoutManager
14
14
  import androidx.recyclerview.widget.RecyclerView
15
+ import kotlinx.coroutines.CoroutineScope
16
+ import kotlinx.coroutines.Dispatchers
17
+ import kotlinx.coroutines.Job
18
+ import kotlinx.coroutines.SupervisorJob
19
+ import kotlinx.coroutines.cancel
20
+ import kotlinx.coroutines.currentCoroutineContext
21
+ import kotlinx.coroutines.ensureActive
22
+ import kotlinx.coroutines.launch
23
+ import kotlinx.coroutines.sync.Mutex
24
+ import kotlinx.coroutines.sync.withLock
25
+ import kotlinx.coroutines.withContext
15
26
  import java.text.Normalizer
16
27
  import java.util.Locale
17
- import java.util.concurrent.ExecutorService
18
- import java.util.concurrent.Executors
19
- import java.util.concurrent.Future
20
28
 
21
29
  class EmojiSheetUIView(context: Context) : LinearLayout(context) {
22
30
 
@@ -26,23 +34,34 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
26
34
  private const val FREQ_DAY_SUFFIX = "_day"
27
35
  private const val FREQ_TIME_SUFFIX = "_time"
28
36
  private val COMBINING_MARKS_REGEX = "\\p{Mn}+".toRegex()
29
- // Emoji data + translation keywords are parsed once on a background thread
30
- // and cached for the app's lifetime. The cacheLock prevents concurrent warmCache()
31
- // and loadDataAsync() from parsing the same data twice.
32
- private val cacheLock = Any()
37
+ private val cacheScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
38
+ private val cacheMutex = Mutex()
33
39
  @Volatile
34
40
  private var cachedData: Pair<List<EmojiCategory>, Map<String, List<String>>>? = null
35
41
 
36
42
  fun warmCache(context: Context) {
37
43
  if (cachedData != null) return
38
- Thread {
39
- synchronized(cacheLock) {
40
- if (cachedData != null) return@Thread
44
+ val appContext = context.applicationContext
45
+ cacheScope.launch {
46
+ loadCachedData(appContext)
47
+ }
48
+ }
49
+
50
+ private suspend fun loadCachedData(context: Context): Pair<List<EmojiCategory>, Map<String, List<String>>> {
51
+ cachedData?.let { return it }
52
+
53
+ return cacheMutex.withLock {
54
+ cachedData?.let { return it }
55
+
56
+ try {
41
57
  val categories = EmojiData.loadCategories(context)
42
58
  val keywords = loadAllKeywords(context)
43
- cachedData = Pair(categories, keywords)
59
+ Pair(categories, keywords).also { cachedData = it }
60
+ } catch (e: Exception) {
61
+ android.util.Log.e("EmojiSheet", "Failed to load emoji data", e)
62
+ throw e
44
63
  }
45
- }.start()
64
+ }
46
65
  }
47
66
 
48
67
  private fun loadAllKeywords(context: Context): Map<String, List<String>> {
@@ -120,6 +139,9 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
120
139
  private var velocityTracker: VelocityTracker? = null
121
140
  private var topPullStartY: Float? = null
122
141
  private val topPullActivationThresholdPx = 24f * context.resources.displayMetrics.density
142
+ private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
143
+ private var loadJob: Job? = null
144
+ private var searchJob: Job? = null
123
145
 
124
146
  init {
125
147
  orientation = VERTICAL
@@ -415,34 +437,20 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
415
437
  set(value) { field = value; if (value != null) emptyStateLabel.text = value }
416
438
 
417
439
  fun loadDataAsync() {
418
- val cached = cachedData
419
- if (cached != null) {
420
- allCategories = cached.first
421
- localizedKeywords = cached.second
440
+ loadJob?.cancel()
441
+ val appContext = context.applicationContext
442
+ loadJob = viewScope.launch {
443
+ val data = withContext(Dispatchers.Default) {
444
+ loadCachedData(appContext)
445
+ }
446
+ val categories = data.first
447
+ val keywords = data.second
448
+ allCategories = categories
449
+ localizedKeywords = keywords
422
450
  allCategoryKeys = buildCategoryKeys()
423
451
  rebuildCategoryStrip()
424
452
  buildAndSetItems()
425
- return
426
453
  }
427
-
428
- Thread {
429
- val data = synchronized(cacheLock) {
430
- cachedData ?: run {
431
- val categories = EmojiData.loadCategories(context)
432
- val keywords = loadLocalizedKeywords()
433
- Pair(categories, keywords).also { cachedData = it }
434
- }
435
- }
436
- val categories = data.first
437
- val keywords = data.second
438
- post {
439
- allCategories = categories
440
- localizedKeywords = keywords
441
- allCategoryKeys = buildCategoryKeys()
442
- rebuildCategoryStrip()
443
- buildAndSetItems()
444
- }
445
- }.start()
446
454
  }
447
455
 
448
456
  fun updateTheme(theme: String) {
@@ -594,17 +602,14 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
594
602
  }
595
603
  }
596
604
 
597
- // Search runs on a single background thread to avoid blocking the UI.
598
- // A single-thread executor ensures only one search runs at a time
599
- // new keystrokes cancel the previous task via Future.cancel(true)
600
- // and the generation counter provides a secondary guard against stale results.
605
+ // Search runs on a cancellable coroutine job. The generation counter remains
606
+ // as a small secondary guard so stale results cannot apply after a newer query.
601
607
  private var searchGeneration = 0
602
- private val searchExecutor: ExecutorService = Executors.newSingleThreadExecutor()
603
- private var searchFuture: Future<*>? = null
604
608
 
605
609
  private fun onSearch(query: String) {
606
610
  val trimmedQuery = query.trim()
607
611
  currentSearchQuery = trimmedQuery
612
+ searchJob?.cancel()
608
613
  if (trimmedQuery.isEmpty()) {
609
614
  searchGeneration += 1
610
615
  isSearchActive = false
@@ -625,46 +630,49 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
625
630
  val categories = allCategories
626
631
  val keywords = localizedKeywords
627
632
 
628
- // Cancel previous search task
629
- searchFuture?.cancel(true)
630
633
  val exclude = excludeEmojis
631
- searchFuture = searchExecutor.submit {
632
- val normalizedQueryVariants = normalizedSearchVariants(trimmedQuery)
633
- val scored = mutableListOf<Pair<EmojiGridAdapter.ListItem.Emoji, Int>>()
634
-
635
- for (cat in categories) {
636
- if (Thread.currentThread().isInterrupted || generation != searchGeneration) return@submit
637
- for (emoji in cat.data) {
638
- if (emoji.id in exclude) continue
639
- val score = relevanceScore(emoji, normalizedQueryVariants, keywords)
640
- if (score > 0) {
641
- val resolved = resolveSkinTone(emoji.emoji, emoji.id, emoji.toneEnabled)
642
- scored.add(Pair(EmojiGridAdapter.ListItem.Emoji(
643
- emoji = resolved, name = emoji.name,
644
- toneEnabled = emoji.toneEnabled, keywords = emoji.keywords, id = emoji.id
645
- ), score))
634
+ searchJob = viewScope.launch {
635
+ val matchedItems = withContext(Dispatchers.Default) {
636
+ val normalizedQueryVariants = normalizedSearchVariants(trimmedQuery)
637
+ val scored = mutableListOf<Pair<EmojiGridAdapter.ListItem.Emoji, Int>>()
638
+
639
+ for (cat in categories) {
640
+ currentCoroutineContext().ensureActive()
641
+ for (emoji in cat.data) {
642
+ currentCoroutineContext().ensureActive()
643
+ if (emoji.id in exclude) continue
644
+ val score = relevanceScore(emoji, normalizedQueryVariants, keywords)
645
+ if (score > 0) {
646
+ val resolved = resolveSkinTone(emoji.emoji, emoji.id, emoji.toneEnabled)
647
+ scored.add(Pair(EmojiGridAdapter.ListItem.Emoji(
648
+ emoji = resolved,
649
+ name = emoji.name,
650
+ toneEnabled = emoji.toneEnabled,
651
+ keywords = emoji.keywords,
652
+ id = emoji.id
653
+ ), score))
654
+ }
646
655
  }
647
656
  }
657
+
658
+ scored.sortByDescending { it.second }
659
+ scored.map { it.first }
648
660
  }
649
661
 
650
- // Sort by relevance score descending
651
- scored.sortByDescending { it.second }
662
+ if (generation != searchGeneration) return@launch
652
663
 
653
- if (Thread.currentThread().isInterrupted || generation != searchGeneration) return@submit
654
- post {
655
- if (generation != searchGeneration) return@post
656
- val results = mutableListOf<EmojiGridAdapter.ListItem>()
657
- val sectionPositions = mutableListOf<Int>()
664
+ val results = mutableListOf<EmojiGridAdapter.ListItem>()
665
+ val sectionPositions = mutableListOf<Int>()
666
+ if (matchedItems.isNotEmpty()) {
658
667
  sectionPositions.add(0)
659
668
  results.add(EmojiGridAdapter.ListItem.Header("Search Results", "search"))
660
- for ((item, _) in scored) { results.add(item) }
661
-
662
- val hasResults = results.size > 1
663
- emptyStateLabel.visibility = if (hasResults) View.GONE else View.VISIBLE
664
- recyclerView.visibility = if (hasResults) View.VISIBLE else View.GONE
665
- gridAdapter.setItems(results, sectionPositions)
666
- recyclerView.scrollToPosition(0)
669
+ results.addAll(matchedItems)
667
670
  }
671
+
672
+ emptyStateLabel.visibility = if (matchedItems.isNotEmpty()) View.GONE else View.VISIBLE
673
+ recyclerView.visibility = if (matchedItems.isNotEmpty()) View.VISIBLE else View.GONE
674
+ gridAdapter.setItems(results, sectionPositions)
675
+ recyclerView.scrollToPosition(0)
668
676
  }
669
677
  }
670
678
 
@@ -709,13 +717,6 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
709
717
  }
710
718
  return null
711
719
  }
712
-
713
- // --- Localized Search ---
714
-
715
- private fun loadLocalizedKeywords(): Map<String, List<String>> {
716
- return loadAllKeywords(context)
717
- }
718
-
719
720
  // --- Frequently Used ---
720
721
 
721
722
  private fun getFreqPrefs(): SharedPreferences =
@@ -858,7 +859,8 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
858
859
 
859
860
  override fun onDetachedFromWindow() {
860
861
  super.onDetachedFromWindow()
861
- searchFuture?.cancel(true)
862
- searchExecutor.shutdownNow()
862
+ loadJob?.cancel()
863
+ searchJob?.cancel()
864
+ viewScope.cancel()
863
865
  }
864
866
  }
@@ -251,11 +251,12 @@ class EmojiGridView: UIView, UICollectionViewDataSource, UICollectionViewDelegat
251
251
  selectionFeedback.selectionChanged()
252
252
  }
253
253
  if enableAnimations, let cell = collectionView.cellForItem(at: indexPath) {
254
+ let path = indexPath
254
255
  UIView.animate(withDuration: 0.08, delay: 0, options: [.allowUserInteraction]) {
255
256
  cell.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)
256
- } completion: { _ in
257
+ } completion: { [weak collectionView] _ in
257
258
  UIView.animate(withDuration: 0.08) {
258
- cell.transform = .identity
259
+ collectionView?.cellForItem(at: path)?.transform = .identity
259
260
  }
260
261
  }
261
262
  }
@@ -3,7 +3,7 @@ import UIKit
3
3
 
4
4
  // MARK: - Data Models
5
5
 
6
- struct EmojiItem {
6
+ struct EmojiItem: Sendable {
7
7
  let emoji: String
8
8
  let name: String
9
9
  let version: String
@@ -12,7 +12,7 @@ struct EmojiItem {
12
12
  let id: String
13
13
  }
14
14
 
15
- struct EmojiSection {
15
+ struct EmojiSection: Sendable {
16
16
  let title: String
17
17
  var data: [EmojiItem]
18
18
  }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'EmojiSheetModule'
3
- s.version = '1.8.1'
3
+ s.version = '1.9.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 = ''
@@ -12,6 +12,69 @@ private enum FrequentlyUsedKeys {
12
12
  static let timestamp = "timestamp"
13
13
  }
14
14
 
15
+ private struct EmojiDataSnapshot: Sendable {
16
+ let sections: [EmojiSection]
17
+ let keywords: [String: [String]]
18
+ }
19
+
20
+ private actor EmojiDataCacheStorage {
21
+ private var cachedSnapshot: EmojiDataSnapshot?
22
+
23
+ func cached() -> EmojiDataSnapshot? { cachedSnapshot }
24
+
25
+ @discardableResult
26
+ func store(_ snapshot: EmojiDataSnapshot) -> EmojiDataSnapshot {
27
+ if let existing = cachedSnapshot { return existing }
28
+ cachedSnapshot = snapshot
29
+ return snapshot
30
+ }
31
+ }
32
+
33
+ private final class EmojiDataCache {
34
+ static let shared = EmojiDataCache()
35
+
36
+ private let storage = EmojiDataCacheStorage()
37
+ private let stateLock = NSLock()
38
+ private var hasCachedDataSnapshot = false
39
+
40
+ private init() {}
41
+
42
+ var hasCachedData: Bool {
43
+ stateLock.withLock { hasCachedDataSnapshot }
44
+ }
45
+
46
+ func warm(loader: @escaping @Sendable () -> EmojiDataSnapshot) {
47
+ guard !hasCachedData else { return }
48
+ Task.detached(priority: .utility) {
49
+ _ = await self.load(loader: loader)
50
+ }
51
+ }
52
+
53
+ func load(loader: @escaping @Sendable () -> EmojiDataSnapshot) async -> EmojiDataSnapshot {
54
+ if let snapshot = await storage.cached() {
55
+ stateLock.withLock { hasCachedDataSnapshot = true }
56
+ return snapshot
57
+ }
58
+ // Compute outside the actor on a non-cooperative thread to avoid blocking the Swift concurrency thread pool
59
+ let snapshot: EmojiDataSnapshot = await withCheckedContinuation { continuation in
60
+ DispatchQueue.global(qos: .utility).async {
61
+ continuation.resume(returning: loader())
62
+ }
63
+ }
64
+ let result = await storage.store(snapshot)
65
+ stateLock.withLock { hasCachedDataSnapshot = true }
66
+ return result
67
+ }
68
+ }
69
+
70
+ private extension NSLock {
71
+ func withLock<T>(_ body: () -> T) -> T {
72
+ lock()
73
+ defer { unlock() }
74
+ return body()
75
+ }
76
+ }
77
+
15
78
  // MARK: - EmojiSheetUIView
16
79
 
17
80
  class EmojiSheetUIView: UIView,
@@ -80,9 +143,10 @@ class EmojiSheetUIView: UIView,
80
143
  private var frequentlyUsedSection: EmojiSection?
81
144
  private var localizedKeywords: [String: [String]] = [:]
82
145
  private var currentSearchText: String?
83
- // Search runs on a background queue to avoid blocking the UI while iterating
84
- // 1900+ emojis with keyword matching. The generation counter ensures stale
85
- // search results from previous keystrokes are discarded before updating the grid.
146
+ private var loadTask: Task<Void, Never>?
147
+ private var searchTask: Task<Void, Never>?
148
+ // Search work is cancellable. The generation counter remains as a lightweight
149
+ // secondary guard so stale results never apply after a newer query wins.
86
150
  private var searchGeneration: Int = 0
87
151
 
88
152
  private let searchBar = EmojiSearchBar()
@@ -130,6 +194,11 @@ class EmojiSheetUIView: UIView,
130
194
  fatalError("init(coder:) has not been implemented")
131
195
  }
132
196
 
197
+ deinit {
198
+ loadTask?.cancel()
199
+ searchTask?.cancel()
200
+ }
201
+
133
202
  // MARK: - Setup
134
203
 
135
204
  private func setupViews() {
@@ -332,25 +401,19 @@ class EmojiSheetUIView: UIView,
332
401
 
333
402
  // MARK: - Data Loading (cached across instances)
334
403
 
335
- // Emoji data + translation keywords are parsed once on a serial background queue
336
- // and cached for the app's lifetime. The serial queue prevents concurrent warmCache()
337
- // and loadDataAsync() from racing on the same static vars.
338
- private static let cacheQueue = DispatchQueue(label: "EmojiSheetCache")
339
- private static var cachedSections: [EmojiSection]?
340
- private static var cachedKeywords: [String: [String]]?
341
- static var hasCachedData: Bool { cacheQueue.sync { cachedSections != nil && cachedKeywords != nil } }
404
+ private static let dataCache = EmojiDataCache.shared
405
+ static var hasCachedData: Bool { dataCache.hasCachedData }
342
406
 
343
407
  static func warmCache() {
344
- cacheQueue.async {
345
- guard cachedSections == nil || cachedKeywords == nil else { return }
346
- let sections = parseEmojiJSON()
347
- let keywords = loadAllKeywords()
348
- cachedSections = sections
349
- cachedKeywords = keywords
408
+ dataCache.warm {
409
+ EmojiDataSnapshot(
410
+ sections: parseEmojiJSON(),
411
+ keywords: loadAllKeywords()
412
+ )
350
413
  }
351
414
  }
352
415
 
353
- private static func loadAllKeywords() -> [String: [String]] {
416
+ nonisolated private static func loadAllKeywords() -> [String: [String]] {
354
417
  let bundle = Bundle(for: EmojiSheetUIView.self)
355
418
  var merged: [String: [String]] = [:]
356
419
 
@@ -383,7 +446,7 @@ class EmojiSheetUIView: UIView,
383
446
  return merged
384
447
  }
385
448
 
386
- private static func mergeKeywords(from url: URL, into merged: inout [String: [String]]) {
449
+ nonisolated private static func mergeKeywords(from url: URL, into merged: inout [String: [String]]) {
387
450
  guard let data = try? Data(contentsOf: url),
388
451
  let dict = try? JSONSerialization.jsonObject(with: data) as? [String: [String]]
389
452
  else { return }
@@ -398,23 +461,20 @@ class EmojiSheetUIView: UIView,
398
461
  }
399
462
 
400
463
  func loadDataAsync() {
401
- let cached = Self.cacheQueue.sync { (Self.cachedSections, Self.cachedKeywords) }
402
- if let sections = cached.0, let keywords = cached.1 {
403
- self.allSections = sections
404
- self.localizedKeywords = keywords
405
- self.rebuildSections(searchText: nil)
406
- return
407
- }
464
+ loadTask?.cancel()
465
+ loadTask = Task { [weak self] in
466
+ let snapshot = await Self.dataCache.load {
467
+ EmojiDataSnapshot(
468
+ sections: Self.parseEmojiJSON(),
469
+ keywords: Self.loadAllKeywords()
470
+ )
471
+ }
408
472
 
409
- Self.cacheQueue.async { [weak self] in
410
- guard let self else { return }
411
- let sections = Self.cachedSections ?? Self.parseEmojiJSON()
412
- let keywords = Self.cachedKeywords ?? Self.loadAllKeywords()
413
- Self.cachedSections = sections
414
- Self.cachedKeywords = keywords
415
- DispatchQueue.main.async {
416
- self.allSections = sections
417
- self.localizedKeywords = keywords
473
+ guard !Task.isCancelled else { return }
474
+ await MainActor.run {
475
+ guard let self, !Task.isCancelled else { return }
476
+ self.allSections = snapshot.sections
477
+ self.localizedKeywords = snapshot.keywords
418
478
  self.rebuildSections(searchText: nil)
419
479
  }
420
480
  }
@@ -422,7 +482,7 @@ class EmojiSheetUIView: UIView,
422
482
 
423
483
  // iOS version → max Unicode emoji version. Source: https://emojipedia.org/apple
424
484
  // Cases MUST remain ordered most-specific first (Swift evaluates top-to-bottom).
425
- private static func maxSupportedEmojiVersion() -> Double {
485
+ nonisolated private static func maxSupportedEmojiVersion() -> Double {
426
486
  let osVersion = ProcessInfo.processInfo.operatingSystemVersion
427
487
  switch (osVersion.majorVersion, osVersion.minorVersion) {
428
488
  case (18, 4...): return 16.0
@@ -437,7 +497,7 @@ class EmojiSheetUIView: UIView,
437
497
  }
438
498
  }
439
499
 
440
- private static func parseEmojiJSON() -> [EmojiSection] {
500
+ nonisolated private static func parseEmojiJSON() -> [EmojiSection] {
441
501
  let bundle = Bundle(for: EmojiSheetUIView.self)
442
502
  guard let url = bundle.url(forResource: "emojis", withExtension: "json"),
443
503
  let data = try? Data(contentsOf: url),
@@ -471,10 +531,6 @@ class EmojiSheetUIView: UIView,
471
531
  }
472
532
  }
473
533
 
474
- private func loadLocalizedKeywords() -> [String: [String]] {
475
- return Self.loadAllKeywords()
476
- }
477
-
478
534
  private var filteredAllSections: [EmojiSection] {
479
535
  guard !excludeEmojis.isEmpty else { return allSections }
480
536
  return allSections.map { section in
@@ -494,6 +550,7 @@ class EmojiSheetUIView: UIView,
494
550
 
495
551
  private func rebuildSections(searchText: String?) {
496
552
  currentSearchText = searchText
553
+ searchTask?.cancel()
497
554
 
498
555
  guard let search = searchText, !search.isEmpty else {
499
556
  searchGeneration += 1
@@ -524,15 +581,15 @@ class EmojiSheetUIView: UIView,
524
581
  let sections = filteredAllSections
525
582
  let keywords = localizedKeywords
526
583
 
527
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
528
- guard let self else { return }
529
- let searchVariants = self.normalizedSearchVariants(search)
584
+ searchTask = Task(priority: .userInitiated) { [weak self, sections, keywords, search, generation] in
585
+ let searchVariants = Self.normalizedSearchVariants(search)
530
586
  var scored: [(item: EmojiItem, score: Int)] = []
531
587
 
532
588
  for section in sections {
533
- guard generation == self.searchGeneration else { return }
589
+ if Task.isCancelled { return }
534
590
  for item in section.data {
535
- let score = self.relevanceScore(
591
+ if Task.isCancelled { return }
592
+ let score = Self.relevanceScore(
536
593
  item: item,
537
594
  searchVariants: searchVariants,
538
595
  localizedKeywords: keywords
@@ -543,13 +600,12 @@ class EmojiSheetUIView: UIView,
543
600
  }
544
601
  }
545
602
 
546
- // Sort by relevance score descending, then by original order for ties
547
603
  scored.sort { $0.score > $1.score }
548
604
  let matchedItems = scored.map { $0.item }
549
605
 
550
- guard generation == self.searchGeneration else { return }
551
- DispatchQueue.main.async { [weak self] in
552
- guard let self, generation == self.searchGeneration else { return }
606
+ guard !Task.isCancelled else { return }
607
+ await MainActor.run {
608
+ guard let self, !Task.isCancelled, generation == self.searchGeneration else { return }
553
609
  var resultSections: [EmojiSection] = []
554
610
  if !matchedItems.isEmpty {
555
611
  resultSections.append(EmojiSection(title: "search_results", data: matchedItems))
@@ -563,7 +619,7 @@ class EmojiSheetUIView: UIView,
563
619
  }
564
620
  }
565
621
 
566
- private static func localizedKeywordsForEmoji(_ emoji: String, in dict: [String: [String]]) -> [String] {
622
+ nonisolated private static func localizedKeywordsForEmoji(_ emoji: String, in dict: [String: [String]]) -> [String] {
567
623
  if let kw = dict[emoji] { return kw }
568
624
  let stripped = String(emoji.unicodeScalars.filter { $0.value != 0xFE0E && $0.value != 0xFE0F })
569
625
  if stripped != emoji, let kw = dict[stripped] { return kw }
@@ -574,7 +630,7 @@ class EmojiSheetUIView: UIView,
574
630
  // 100 = exact name match, 90 = name starts with, 80 = exact keyword,
575
631
  // 70 = keyword starts with, 50 = name contains, 30 = keyword contains,
576
632
  // 10 = localized keyword contains. Returns 0 for no match.
577
- private func relevanceScore(
633
+ nonisolated private static func relevanceScore(
578
634
  item: EmojiItem,
579
635
  searchVariants: [String],
580
636
  localizedKeywords: [String: [String]]
@@ -630,7 +686,7 @@ class EmojiSheetUIView: UIView,
630
686
  return true
631
687
  }
632
688
 
633
- return normalizedSearchVariants(originalText)
689
+ return Self.normalizedSearchVariants(originalText)
634
690
  .contains { candidateVariant in
635
691
  candidateVariant != normalizedText &&
636
692
  searchVariants.contains(where: { candidateVariant.contains($0) })
@@ -677,7 +733,7 @@ class EmojiSheetUIView: UIView,
677
733
  UserDefaults.standard.set(dict, forKey: Self.frequentlyUsedKey)
678
734
  }
679
735
 
680
- private func normalizeSearchText(_ text: String) -> String {
736
+ nonisolated private static func normalizeSearchText(_ text: String) -> String {
681
737
  text
682
738
  .precomposedStringWithCompatibilityMapping
683
739
  .folding(
@@ -688,7 +744,7 @@ class EmojiSheetUIView: UIView,
688
744
  .lowercased(with: .current)
689
745
  }
690
746
 
691
- private func normalizedSearchVariants(_ text: String) -> [String] {
747
+ nonisolated private static func normalizedSearchVariants(_ text: String) -> [String] {
692
748
  let normalized = normalizeSearchText(text)
693
749
  let transliterated = text.applyingTransform(.toLatin, reverse: false)
694
750
  .map(normalizeSearchText)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-native-sheet-emojis",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",