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.
- package/android/build.gradle +1 -0
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiGridAdapter.kt +5 -0
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiSheetUIView.kt +84 -82
- package/ios/EmojiGridView.swift +3 -2
- package/ios/EmojiSheetContentView.swift +2 -2
- package/ios/EmojiSheetModule.podspec +1 -1
- package/ios/EmojiSheetUIView.swift +110 -54
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
598
|
-
//
|
|
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
|
-
|
|
632
|
-
val
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
val
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
651
|
-
scored.sortByDescending { it.second }
|
|
662
|
+
if (generation != searchGeneration) return@launch
|
|
652
663
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
862
|
-
|
|
862
|
+
loadJob?.cancel()
|
|
863
|
+
searchJob?.cancel()
|
|
864
|
+
viewScope.cancel()
|
|
863
865
|
}
|
|
864
866
|
}
|
package/ios/EmojiGridView.swift
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
589
|
+
if Task.isCancelled { return }
|
|
534
590
|
for item in section.data {
|
|
535
|
-
|
|
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
|
|
551
|
-
|
|
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.
|
|
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",
|