expo-native-sheet-emojis 2.0.2 → 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.
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiData.kt +33 -3
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiSheetContentView.kt +1 -0
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiSheetModule.kt +112 -5
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiSheetUIView.kt +87 -42
- package/ios/EmojiCategoryStrip.swift +39 -13
- package/ios/EmojiGridView.swift +5 -1
- package/ios/EmojiSheetContentView.swift +3 -0
- package/ios/EmojiSheetModule.podspec +1 -1
- package/ios/EmojiSheetModule.swift +73 -20
- package/ios/EmojiSheetUIView.swift +102 -17
- package/package.json +1 -1
|
@@ -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 =
|
|
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("_", " ")
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
815
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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
|
}
|
|
@@ -72,36 +72,62 @@ class EmojiCategoryStrip: UIView, UICollectionViewDataSource, UICollectionViewDe
|
|
|
72
72
|
func updateCategories(_ keys: [String]) {
|
|
73
73
|
categoryKeys = keys
|
|
74
74
|
selectedIndex = 0
|
|
75
|
-
|
|
75
|
+
reloadCategories()
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
func applyTheme(_ theme: EmojiSheetTheme) {
|
|
79
79
|
currentTheme = theme
|
|
80
80
|
backgroundColor = theme.categoryBarBackgroundColor
|
|
81
81
|
dividerLine.backgroundColor = theme.dividerColor
|
|
82
|
-
|
|
82
|
+
reloadCategories()
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
func applyLayoutDirection(_ attribute: UISemanticContentAttribute) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
UIView.performWithoutAnimation {
|
|
87
|
+
semanticContentAttribute = attribute
|
|
88
|
+
collectionView.semanticContentAttribute = attribute
|
|
89
|
+
collectionView.collectionViewLayout.invalidateLayout()
|
|
90
|
+
collectionView.layoutIfNeeded()
|
|
91
|
+
}
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
func selectCategory(at index: Int) {
|
|
92
95
|
guard !isSearchActive, index != selectedIndex, index >= 0, index < categoryKeys.count else { return }
|
|
93
96
|
selectedIndex = index
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
at: IndexPath(item: index, section: 0),
|
|
97
|
-
at: .centeredHorizontally,
|
|
98
|
-
animated: true
|
|
99
|
-
)
|
|
97
|
+
reloadCategories()
|
|
98
|
+
scrollToCategoryIfNeeded(at: index)
|
|
100
99
|
}
|
|
101
100
|
|
|
102
101
|
func setSearchActive(_ active: Bool) {
|
|
103
102
|
isSearchActive = active
|
|
104
|
-
|
|
103
|
+
reloadCategories()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func reloadCategories() {
|
|
107
|
+
UIView.performWithoutAnimation {
|
|
108
|
+
collectionView.reloadData()
|
|
109
|
+
collectionView.layoutIfNeeded()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func scrollToCategoryIfNeeded(at index: Int) {
|
|
114
|
+
guard index >= 0, index < categoryKeys.count else { return }
|
|
115
|
+
|
|
116
|
+
let indexPath = IndexPath(item: index, section: 0)
|
|
117
|
+
guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let visibleBounds = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
|
|
122
|
+
guard !visibleBounds.contains(attributes.frame) else {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
collectionView.scrollToItem(
|
|
127
|
+
at: indexPath,
|
|
128
|
+
at: .centeredHorizontally,
|
|
129
|
+
animated: false
|
|
130
|
+
)
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
// MARK: - UICollectionViewDataSource
|
|
@@ -134,7 +160,7 @@ class EmojiCategoryStrip: UIView, UICollectionViewDataSource, UICollectionViewDe
|
|
|
134
160
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
135
161
|
selectedIndex = indexPath.item
|
|
136
162
|
isSearchActive = false
|
|
137
|
-
|
|
163
|
+
reloadCategories()
|
|
138
164
|
delegate?.categoryStrip(self, didSelectCategoryAt: indexPath.item)
|
|
139
165
|
}
|
|
140
166
|
}
|
package/ios/EmojiGridView.swift
CHANGED
|
@@ -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
|
-
|
|
123
|
+
UIView.performWithoutAnimation {
|
|
124
|
+
collectionView.collectionViewLayout.invalidateLayout()
|
|
125
|
+
collectionView.reloadData()
|
|
126
|
+
collectionView.layoutIfNeeded()
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
func applyTheme(_ theme: EmojiSheetTheme) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Pod::Spec.new do |s|
|
|
2
2
|
s.name = 'EmojiSheetModule'
|
|
3
|
-
s.version = '2.0.
|
|
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 = ''
|
|
@@ -96,6 +96,15 @@ public class EmojiSheetModule: Module {
|
|
|
96
96
|
// MARK: - Presentation via dedicated UIWindow
|
|
97
97
|
|
|
98
98
|
private func presentSheet(options: [String: Any], promise: Promise) {
|
|
99
|
+
guard currentPromise == nil, sheetViewController == nil else {
|
|
100
|
+
promise.resolve(["cancelled": true])
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if overlayWindow != nil {
|
|
105
|
+
tearDownWindow()
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
guard let windowScene = UIApplication.shared.connectedScenes
|
|
100
109
|
.compactMap({ $0 as? UIWindowScene })
|
|
101
110
|
.first(where: { $0.activationState == .foregroundActive })
|
|
@@ -208,7 +217,10 @@ public class EmojiSheetModule: Module {
|
|
|
208
217
|
sheetBackgroundColor: bgColor,
|
|
209
218
|
theme: customTheme
|
|
210
219
|
)
|
|
211
|
-
|
|
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
|
|
212
224
|
sheetVC.gestureEnabled = gestureEnabled
|
|
213
225
|
sheetVC.embedPickerView(pickerView)
|
|
214
226
|
sheetVC.onAppear = { [weak self] in
|
|
@@ -320,6 +332,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
320
332
|
var onAppear: (() -> Void)?
|
|
321
333
|
var onDismiss: (() -> Void)?
|
|
322
334
|
var mediumDetentRatio: CGFloat = 0.5
|
|
335
|
+
var largeDetentRatio: CGFloat = 1.0
|
|
323
336
|
var gestureEnabled: Bool = true
|
|
324
337
|
|
|
325
338
|
private let backdropView = UIView()
|
|
@@ -378,7 +391,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
378
391
|
options: [.curveEaseOut]
|
|
379
392
|
) {
|
|
380
393
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
381
|
-
self.
|
|
394
|
+
self.applyDetentLayout(self.currentDetent)
|
|
382
395
|
} completion: { [weak self] _ in
|
|
383
396
|
self?.onAppear?()
|
|
384
397
|
}
|
|
@@ -395,7 +408,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
395
408
|
}
|
|
396
409
|
|
|
397
410
|
guard hasPresented, !isAnimatingDismissal else { return }
|
|
398
|
-
|
|
411
|
+
applyDetentLayout(currentDetent)
|
|
399
412
|
}
|
|
400
413
|
|
|
401
414
|
func embedPickerView(_ embeddedView: UIView) {
|
|
@@ -445,8 +458,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
445
458
|
currentDetent = detent
|
|
446
459
|
|
|
447
460
|
let updates = {
|
|
448
|
-
self.
|
|
461
|
+
self.applyDetentLayout(detent)
|
|
449
462
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
463
|
+
self.view.layoutIfNeeded()
|
|
450
464
|
}
|
|
451
465
|
|
|
452
466
|
if animated {
|
|
@@ -488,12 +502,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
488
502
|
.union(.beginFromCurrentState)
|
|
489
503
|
|
|
490
504
|
let overlap = keyboardOverlap(for: endFrame)
|
|
505
|
+
let wasKeyboardVisible = keyboardOverlap > 0
|
|
491
506
|
let targetDetent: Detent
|
|
492
507
|
|
|
493
|
-
if overlap
|
|
508
|
+
if wasKeyboardVisible, overlap == 0 {
|
|
509
|
+
targetDetent = .medium
|
|
510
|
+
} else if overlap > 0, isSearchFocused {
|
|
494
511
|
targetDetent = .large
|
|
495
|
-
} else if overlap == 0, !isSearchFocused, let previousDetent = detentBeforeSearchFocus {
|
|
496
|
-
targetDetent = previousDetent
|
|
497
512
|
} else {
|
|
498
513
|
targetDetent = currentDetent
|
|
499
514
|
}
|
|
@@ -504,13 +519,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
504
519
|
options: animationOptions
|
|
505
520
|
) {
|
|
506
521
|
self.keyboardOverlap = overlap
|
|
507
|
-
self.
|
|
508
|
-
self.sheetContainerView.transform = self.transform(for: targetDetent)
|
|
522
|
+
self.applyDetentLayout(targetDetent, keyboardOverlap: overlap)
|
|
509
523
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
510
524
|
self.view.layoutIfNeeded()
|
|
511
525
|
} completion: { _ in
|
|
512
526
|
self.currentDetent = targetDetent
|
|
513
|
-
if overlap == 0
|
|
527
|
+
if overlap == 0 {
|
|
528
|
+
self.isSearchFocused = false
|
|
514
529
|
self.detentBeforeSearchFocus = nil
|
|
515
530
|
}
|
|
516
531
|
}
|
|
@@ -543,6 +558,8 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
543
558
|
|
|
544
559
|
private func updateSheetDrag(_ distance: CGFloat) {
|
|
545
560
|
guard !isAnimatingDismissal, currentDetent == .large else { return }
|
|
561
|
+
dismissKeyboardForSheetDrag()
|
|
562
|
+
|
|
546
563
|
let adjustedDistance = max(0, distance * 0.7)
|
|
547
564
|
sheetContainerView.layer.removeAllAnimations()
|
|
548
565
|
sheetContainerView.transform = CGAffineTransform(translationX: 0, y: adjustedDistance)
|
|
@@ -570,8 +587,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
570
587
|
initialSpringVelocity: 0.1,
|
|
571
588
|
options: [.curveEaseOut]
|
|
572
589
|
) {
|
|
573
|
-
self.
|
|
590
|
+
self.applyDetentLayout(.large)
|
|
574
591
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
592
|
+
self.view.layoutIfNeeded()
|
|
575
593
|
}
|
|
576
594
|
}
|
|
577
595
|
|
|
@@ -683,6 +701,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
683
701
|
|
|
684
702
|
switch gesture.state {
|
|
685
703
|
case .changed:
|
|
704
|
+
if gesture.translation(in: view).y > 0 {
|
|
705
|
+
dismissKeyboardForSheetDrag()
|
|
706
|
+
}
|
|
686
707
|
sheetContainerView.transform = CGAffineTransform(translationX: 0, y: translationY)
|
|
687
708
|
backdropView.alpha = Layout.backdropAlpha * (1 - dismissProgress)
|
|
688
709
|
case .ended, .cancelled:
|
|
@@ -718,29 +739,61 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
718
739
|
}
|
|
719
740
|
}
|
|
720
741
|
|
|
742
|
+
private func dismissKeyboardForSheetDrag() {
|
|
743
|
+
view.endEditing(true)
|
|
744
|
+
}
|
|
745
|
+
|
|
721
746
|
private var dismissalTranslation: CGFloat {
|
|
722
747
|
sheetContainerView.bounds.height + view.safeAreaInsets.bottom + 24
|
|
723
748
|
}
|
|
724
749
|
|
|
725
|
-
private func
|
|
726
|
-
|
|
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))
|
|
727
758
|
}
|
|
728
759
|
|
|
729
760
|
private func transformForDismissal() -> CGAffineTransform {
|
|
730
761
|
CGAffineTransform(translationX: 0, y: dismissalTranslation)
|
|
731
762
|
}
|
|
732
763
|
|
|
733
|
-
private func
|
|
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 {
|
|
734
778
|
switch detent {
|
|
735
779
|
case .large:
|
|
736
|
-
|
|
737
|
-
case .medium:
|
|
738
|
-
let containerHeight = max(sheetContainerView.bounds.height, 0)
|
|
739
|
-
let visibleHeight = max(
|
|
780
|
+
let mediumVisibleHeight = max(
|
|
740
781
|
Layout.minimumMediumVisibleHeight,
|
|
741
|
-
|
|
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
|
+
)
|
|
742
795
|
)
|
|
743
|
-
return max(0,
|
|
796
|
+
return max(0, visibleHeight)
|
|
744
797
|
}
|
|
745
798
|
}
|
|
746
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
|
-
|
|
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
|
-
|
|
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:
|
|
519
|
+
existing.append(contentsOf: normalized)
|
|
456
520
|
merged[key] = existing
|
|
457
521
|
} else {
|
|
458
|
-
merged[key] =
|
|
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(
|
|
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
|
|
643
|
-
if
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
"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",
|