expo-native-sheet-emojis 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +139 -11
- package/android/src/main/java/expo/community/modules/emojisheet/EmojiSheetUIView.kt +87 -42
- package/ios/EmojiGridView.swift +5 -1
- package/ios/EmojiSheetContentView.swift +3 -0
- package/ios/EmojiSheetModule.podspec +1 -1
- package/ios/EmojiSheetModule.swift +100 -29
- 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,11 +8,15 @@ import android.view.View
|
|
|
8
8
|
import android.view.ViewGroup
|
|
9
9
|
import android.view.animation.AccelerateDecelerateInterpolator
|
|
10
10
|
import android.view.WindowManager
|
|
11
|
+
import android.view.inputmethod.InputMethodManager
|
|
12
|
+
import android.widget.Button
|
|
13
|
+
import android.widget.FrameLayout
|
|
14
|
+
import android.widget.LinearLayout
|
|
15
|
+
import androidx.core.view.AccessibilityDelegateCompat
|
|
11
16
|
import androidx.core.view.ViewCompat
|
|
12
17
|
import androidx.core.view.WindowCompat
|
|
13
18
|
import androidx.core.view.WindowInsetsCompat
|
|
14
|
-
import
|
|
15
|
-
import android.widget.LinearLayout
|
|
19
|
+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
16
20
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
17
21
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
18
22
|
import expo.modules.kotlin.Promise
|
|
@@ -241,6 +245,7 @@ class EmojiSheetModule : Module() {
|
|
|
241
245
|
}
|
|
242
246
|
}
|
|
243
247
|
pickerView.onPullDownAtTopDrag = { distance ->
|
|
248
|
+
dismissKeyboard()
|
|
244
249
|
updateSheetDrag(bottomSheet, distance)
|
|
245
250
|
}
|
|
246
251
|
pickerView.onPullDownAtTopRelease = { distance, velocity ->
|
|
@@ -248,22 +253,44 @@ class EmojiSheetModule : Module() {
|
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
// Drag handle
|
|
256
|
+
val handleTargetHeight = (24 * density).toInt()
|
|
251
257
|
val handleBar = View(activity).apply {
|
|
252
258
|
val width = (40 * density).toInt()
|
|
253
259
|
val height = (4 * density).toInt()
|
|
254
|
-
layoutParams =
|
|
255
|
-
gravity = Gravity.CENTER_HORIZONTAL
|
|
260
|
+
layoutParams = FrameLayout.LayoutParams(width, height, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply {
|
|
256
261
|
topMargin = (8 * density).toInt()
|
|
257
|
-
bottomMargin = (4 * density).toInt()
|
|
258
262
|
}
|
|
259
263
|
background = GradientDrawable().apply {
|
|
260
264
|
setColor(handleColor)
|
|
261
265
|
cornerRadius = height / 2f
|
|
262
266
|
}
|
|
263
267
|
}
|
|
268
|
+
val handleTouchTarget = FrameLayout(activity).apply {
|
|
269
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
270
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
271
|
+
handleTargetHeight
|
|
272
|
+
)
|
|
273
|
+
contentDescription = "Dismiss emoji sheet"
|
|
274
|
+
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
|
275
|
+
isClickable = true
|
|
276
|
+
isFocusable = true
|
|
277
|
+
setOnClickListener { dismissSheet(cancelled = true) }
|
|
278
|
+
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
|
|
279
|
+
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
|
|
280
|
+
super.onInitializeAccessibilityNodeInfo(host, info)
|
|
281
|
+
info.className = Button::class.java.name
|
|
282
|
+
info.hintText = "Double tap to dismiss."
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
addView(handleBar)
|
|
286
|
+
}
|
|
264
287
|
|
|
265
288
|
// Container
|
|
266
|
-
val halfExpandedRatio = snapPoints.firstOrNull()?.toFloat() ?: 0.
|
|
289
|
+
val halfExpandedRatio = (snapPoints.firstOrNull()?.toFloat() ?: 0.5f).coerceIn(0.05f, 1f)
|
|
290
|
+
val expandedRatio = maxOf(
|
|
291
|
+
halfExpandedRatio,
|
|
292
|
+
(snapPoints.getOrNull(1)?.toFloat() ?: 1f).coerceIn(0.05f, 1f)
|
|
293
|
+
)
|
|
267
294
|
val minSheetHeight = (activity.resources.displayMetrics.heightPixels * halfExpandedRatio).toInt()
|
|
268
295
|
val cornerRadius = 16 * density
|
|
269
296
|
val container = LinearLayout(activity).apply {
|
|
@@ -279,7 +306,7 @@ class EmojiSheetModule : Module() {
|
|
|
279
306
|
}
|
|
280
307
|
clipToOutline = true
|
|
281
308
|
outlineProvider = android.view.ViewOutlineProvider.BACKGROUND
|
|
282
|
-
addView(
|
|
309
|
+
addView(handleTouchTarget)
|
|
283
310
|
val pickerLp = LinearLayout.LayoutParams(
|
|
284
311
|
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
|
|
285
312
|
)
|
|
@@ -288,16 +315,41 @@ class EmojiSheetModule : Module() {
|
|
|
288
315
|
|
|
289
316
|
bottomSheet.setContentView(container)
|
|
290
317
|
|
|
291
|
-
|
|
318
|
+
var wasKeyboardVisible = false
|
|
319
|
+
val applyDialogInsets: (WindowInsetsCompat) -> Unit = { insets ->
|
|
292
320
|
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
|
293
321
|
val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
294
|
-
|
|
322
|
+
val navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
|
323
|
+
val keyboardVisible = imeInsets.bottom > navigationInsets.bottom
|
|
324
|
+
container.setPadding(
|
|
295
325
|
systemBarInsets.left,
|
|
296
326
|
0,
|
|
297
327
|
systemBarInsets.right,
|
|
298
|
-
|
|
328
|
+
if (keyboardVisible) 0 else systemBarInsets.bottom
|
|
329
|
+
)
|
|
330
|
+
updateSheetForKeyboard(
|
|
331
|
+
bottomSheet,
|
|
332
|
+
pickerView,
|
|
333
|
+
minSheetHeight,
|
|
334
|
+
expandedRatio,
|
|
335
|
+
imeInsets.bottom,
|
|
336
|
+
navigationInsets.bottom
|
|
299
337
|
)
|
|
300
|
-
|
|
338
|
+
if (keyboardVisible && bottomSheet.behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
|
339
|
+
pickerView.setSheetExpansionInProgress(true)
|
|
340
|
+
bottomSheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
341
|
+
}
|
|
342
|
+
val shouldRestoreInitialSnap = wasKeyboardVisible &&
|
|
343
|
+
!keyboardVisible &&
|
|
344
|
+
bottomSheet.behavior.state != BottomSheetBehavior.STATE_HIDDEN
|
|
345
|
+
if (shouldRestoreInitialSnap) {
|
|
346
|
+
bottomSheet.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
|
347
|
+
}
|
|
348
|
+
wasKeyboardVisible = keyboardVisible
|
|
349
|
+
}
|
|
350
|
+
ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
|
|
351
|
+
applyDialogInsets(insets)
|
|
352
|
+
insets
|
|
301
353
|
}
|
|
302
354
|
|
|
303
355
|
// Strip ALL backgrounds from BottomSheet internals
|
|
@@ -305,22 +357,37 @@ class EmojiSheetModule : Module() {
|
|
|
305
357
|
val d = dlg as BottomSheetDialog
|
|
306
358
|
val bottomSheetInternal = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
|
307
359
|
bottomSheetContentView = bottomSheetInternal
|
|
360
|
+
d.findViewById<View>(com.google.android.material.R.id.container)?.fitsSystemWindows = false
|
|
361
|
+
d.findViewById<View>(com.google.android.material.R.id.coordinator)?.fitsSystemWindows = false
|
|
308
362
|
bottomSheetInternal?.apply {
|
|
363
|
+
fitsSystemWindows = false
|
|
309
364
|
setBackgroundColor(Color.TRANSPARENT)
|
|
310
365
|
(parent as? View)?.setBackgroundColor(Color.TRANSPARENT)
|
|
366
|
+
ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
|
|
367
|
+
applyDialogInsets(insets)
|
|
368
|
+
insets
|
|
369
|
+
}
|
|
311
370
|
}
|
|
371
|
+
bottomSheet.window?.let { window ->
|
|
372
|
+
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
373
|
+
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
374
|
+
}
|
|
375
|
+
ViewCompat.requestApplyInsets(container)
|
|
376
|
+
bottomSheetInternal?.let { ViewCompat.requestApplyInsets(it) }
|
|
312
377
|
pickerView.loadDataAsync()
|
|
313
378
|
sendEvent("onSheetOpened", android.os.Bundle())
|
|
314
379
|
}
|
|
315
380
|
|
|
316
381
|
bottomSheet.window?.let { window ->
|
|
317
382
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
383
|
+
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
318
384
|
}
|
|
319
385
|
bottomSheet.window?.setDimAmount(backdropOpacity)
|
|
320
386
|
|
|
321
387
|
bottomSheet.behavior.apply {
|
|
322
388
|
state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
|
323
389
|
this.halfExpandedRatio = halfExpandedRatio
|
|
390
|
+
expandedOffset = (activity.resources.displayMetrics.heightPixels * (1f - expandedRatio)).toInt()
|
|
324
391
|
peekHeight = minSheetHeight
|
|
325
392
|
isHideable = true
|
|
326
393
|
skipCollapsed = false
|
|
@@ -328,6 +395,9 @@ class EmojiSheetModule : Module() {
|
|
|
328
395
|
isDraggable = gestureEnabled
|
|
329
396
|
addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
|
330
397
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
398
|
+
if (newState == BottomSheetBehavior.STATE_DRAGGING) {
|
|
399
|
+
dismissKeyboard()
|
|
400
|
+
}
|
|
331
401
|
pickerView.setSheetExpanded(newState == BottomSheetBehavior.STATE_EXPANDED)
|
|
332
402
|
pickerView.setSheetExpansionInProgress(
|
|
333
403
|
newState == BottomSheetBehavior.STATE_DRAGGING ||
|
|
@@ -365,6 +435,64 @@ class EmojiSheetModule : Module() {
|
|
|
365
435
|
dialog = null
|
|
366
436
|
}
|
|
367
437
|
|
|
438
|
+
private fun dismissKeyboard() {
|
|
439
|
+
val activity = appContext.currentActivity ?: return
|
|
440
|
+
val focusedView = activity.currentFocus
|
|
441
|
+
val tokenView = focusedView
|
|
442
|
+
?: bottomSheetContentView
|
|
443
|
+
?: dialog?.window?.decorView
|
|
444
|
+
?: return
|
|
445
|
+
val inputMethodManager = tokenView.context
|
|
446
|
+
.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
447
|
+
?: return
|
|
448
|
+
|
|
449
|
+
inputMethodManager.hideSoftInputFromWindow(tokenView.windowToken, 0)
|
|
450
|
+
focusedView?.clearFocus()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private fun updateSheetForKeyboard(
|
|
454
|
+
bottomSheet: BottomSheetDialog,
|
|
455
|
+
pickerView: EmojiSheetUIView,
|
|
456
|
+
minSheetHeight: Int,
|
|
457
|
+
expandedRatio: Float,
|
|
458
|
+
imeBottom: Int,
|
|
459
|
+
navigationBottom: Int
|
|
460
|
+
) {
|
|
461
|
+
val keyboardHeight = if (imeBottom > navigationBottom) imeBottom else 0
|
|
462
|
+
val keyboardVisible = keyboardHeight > 0
|
|
463
|
+
val sheetView = bottomSheetContentView
|
|
464
|
+
?: bottomSheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
|
465
|
+
|
|
466
|
+
pickerView.setKeyboardDockedToSheet(keyboardVisible)
|
|
467
|
+
|
|
468
|
+
if (sheetView == null) return
|
|
469
|
+
|
|
470
|
+
val parentHeight = ((sheetView.parent as? View)?.height ?: 0)
|
|
471
|
+
.takeIf { it > 0 }
|
|
472
|
+
?: sheetView.rootView.height.takeIf { it > 0 }
|
|
473
|
+
?: sheetView.resources.displayMetrics.heightPixels
|
|
474
|
+
val desiredExpandedHeight = maxOf(1, (parentHeight * expandedRatio).toInt())
|
|
475
|
+
val targetHeight = if (keyboardVisible) {
|
|
476
|
+
minOf(maxOf(1, parentHeight - keyboardHeight), desiredExpandedHeight)
|
|
477
|
+
} else {
|
|
478
|
+
ViewGroup.LayoutParams.MATCH_PARENT
|
|
479
|
+
}
|
|
480
|
+
bottomSheet.behavior.expandedOffset = if (keyboardVisible) {
|
|
481
|
+
maxOf(0, parentHeight - keyboardHeight - targetHeight)
|
|
482
|
+
} else {
|
|
483
|
+
maxOf(0, parentHeight - desiredExpandedHeight)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
val layoutParams = sheetView.layoutParams
|
|
487
|
+
if (layoutParams.height != targetHeight) {
|
|
488
|
+
layoutParams.height = targetHeight
|
|
489
|
+
sheetView.layoutParams = layoutParams
|
|
490
|
+
}
|
|
491
|
+
sheetView.translationY = 0f
|
|
492
|
+
bottomSheet.behavior.peekHeight = minSheetHeight
|
|
493
|
+
sheetView.requestLayout()
|
|
494
|
+
}
|
|
495
|
+
|
|
368
496
|
private fun updateSheetDrag(bottomSheet: BottomSheetDialog, distance: Float) {
|
|
369
497
|
val sheetView = bottomSheetContentView
|
|
370
498
|
?: bottomSheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
|
@@ -10,6 +10,8 @@ import android.view.VelocityTracker
|
|
|
10
10
|
import android.widget.FrameLayout
|
|
11
11
|
import android.widget.LinearLayout
|
|
12
12
|
import android.widget.TextView
|
|
13
|
+
import androidx.core.view.ViewCompat
|
|
14
|
+
import androidx.core.view.WindowInsetsCompat
|
|
13
15
|
import androidx.recyclerview.widget.GridLayoutManager
|
|
14
16
|
import androidx.recyclerview.widget.RecyclerView
|
|
15
17
|
import kotlinx.coroutines.CoroutineScope
|
|
@@ -23,8 +25,6 @@ import kotlinx.coroutines.launch
|
|
|
23
25
|
import kotlinx.coroutines.sync.Mutex
|
|
24
26
|
import kotlinx.coroutines.sync.withLock
|
|
25
27
|
import kotlinx.coroutines.withContext
|
|
26
|
-
import java.text.Normalizer
|
|
27
|
-
import java.util.Locale
|
|
28
28
|
|
|
29
29
|
class EmojiSheetUIView(context: Context) : LinearLayout(context) {
|
|
30
30
|
|
|
@@ -33,7 +33,6 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
|
|
|
33
33
|
private const val FREQ_COUNT_SUFFIX = "_count"
|
|
34
34
|
private const val FREQ_DAY_SUFFIX = "_day"
|
|
35
35
|
private const val FREQ_TIME_SUFFIX = "_time"
|
|
36
|
-
private val COMBINING_MARKS_REGEX = "\\p{Mn}+".toRegex()
|
|
37
36
|
private val cacheScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
38
37
|
private val cacheMutex = Mutex()
|
|
39
38
|
@Volatile
|
|
@@ -75,14 +74,17 @@ class EmojiSheetUIView(context: Context) : LinearLayout(context) {
|
|
|
75
74
|
val arr = obj.getJSONArray(key)
|
|
76
75
|
val keywords = merged.getOrPut(key) { mutableListOf() }
|
|
77
76
|
for (i in 0 until arr.length()) {
|
|
78
|
-
|
|
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
|
}
|
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.1.0'
|
|
4
4
|
s.summary = 'Native emoji picker bottom sheet for React Native'
|
|
5
5
|
s.description = 'A fully native iOS/Android emoji picker presented in a bottom sheet with search, skin tones, and theming support.'
|
|
6
6
|
s.author = ''
|
|
@@ -217,7 +217,10 @@ public class EmojiSheetModule: Module {
|
|
|
217
217
|
sheetBackgroundColor: bgColor,
|
|
218
218
|
theme: customTheme
|
|
219
219
|
)
|
|
220
|
-
|
|
220
|
+
let mediumRatio = min(max(CGFloat(snapPoints.first ?? 0.5), 0.05), 1)
|
|
221
|
+
let largeRatio = min(max(CGFloat(snapPoints.dropFirst().first ?? 1.0), mediumRatio), 1)
|
|
222
|
+
sheetVC.mediumDetentRatio = mediumRatio
|
|
223
|
+
sheetVC.largeDetentRatio = largeRatio
|
|
221
224
|
sheetVC.gestureEnabled = gestureEnabled
|
|
222
225
|
sheetVC.embedPickerView(pickerView)
|
|
223
226
|
sheetVC.onAppear = { [weak self] in
|
|
@@ -329,10 +332,12 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
329
332
|
var onAppear: (() -> Void)?
|
|
330
333
|
var onDismiss: (() -> Void)?
|
|
331
334
|
var mediumDetentRatio: CGFloat = 0.5
|
|
335
|
+
var largeDetentRatio: CGFloat = 1.0
|
|
332
336
|
var gestureEnabled: Bool = true
|
|
333
337
|
|
|
334
338
|
private let backdropView = UIView()
|
|
335
339
|
private let sheetContainerView = UIView()
|
|
340
|
+
private let grabberHitAreaView = UIControl()
|
|
336
341
|
private let grabberView = UIView()
|
|
337
342
|
private let contentContainerView = UIView()
|
|
338
343
|
private let backdropColor: UIColor
|
|
@@ -387,7 +392,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
387
392
|
options: [.curveEaseOut]
|
|
388
393
|
) {
|
|
389
394
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
390
|
-
self.
|
|
395
|
+
self.applyDetentLayout(self.currentDetent)
|
|
391
396
|
} completion: { [weak self] _ in
|
|
392
397
|
self?.onAppear?()
|
|
393
398
|
}
|
|
@@ -404,7 +409,12 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
404
409
|
}
|
|
405
410
|
|
|
406
411
|
guard hasPresented, !isAnimatingDismissal else { return }
|
|
407
|
-
|
|
412
|
+
applyDetentLayout(currentDetent)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
override func accessibilityPerformEscape() -> Bool {
|
|
416
|
+
requestDismiss()
|
|
417
|
+
return true
|
|
408
418
|
}
|
|
409
419
|
|
|
410
420
|
func embedPickerView(_ embeddedView: UIView) {
|
|
@@ -454,8 +464,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
454
464
|
currentDetent = detent
|
|
455
465
|
|
|
456
466
|
let updates = {
|
|
457
|
-
self.
|
|
467
|
+
self.applyDetentLayout(detent)
|
|
458
468
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
469
|
+
self.view.layoutIfNeeded()
|
|
459
470
|
}
|
|
460
471
|
|
|
461
472
|
if animated {
|
|
@@ -497,12 +508,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
497
508
|
.union(.beginFromCurrentState)
|
|
498
509
|
|
|
499
510
|
let overlap = keyboardOverlap(for: endFrame)
|
|
511
|
+
let wasKeyboardVisible = keyboardOverlap > 0
|
|
500
512
|
let targetDetent: Detent
|
|
501
513
|
|
|
502
|
-
if overlap
|
|
514
|
+
if wasKeyboardVisible, overlap == 0 {
|
|
515
|
+
targetDetent = .medium
|
|
516
|
+
} else if overlap > 0, isSearchFocused {
|
|
503
517
|
targetDetent = .large
|
|
504
|
-
} else if overlap == 0, !isSearchFocused, let previousDetent = detentBeforeSearchFocus {
|
|
505
|
-
targetDetent = previousDetent
|
|
506
518
|
} else {
|
|
507
519
|
targetDetent = currentDetent
|
|
508
520
|
}
|
|
@@ -513,13 +525,13 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
513
525
|
options: animationOptions
|
|
514
526
|
) {
|
|
515
527
|
self.keyboardOverlap = overlap
|
|
516
|
-
self.
|
|
517
|
-
self.sheetContainerView.transform = self.transform(for: targetDetent)
|
|
528
|
+
self.applyDetentLayout(targetDetent, keyboardOverlap: overlap)
|
|
518
529
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
519
530
|
self.view.layoutIfNeeded()
|
|
520
531
|
} completion: { _ in
|
|
521
532
|
self.currentDetent = targetDetent
|
|
522
|
-
if overlap == 0
|
|
533
|
+
if overlap == 0 {
|
|
534
|
+
self.isSearchFocused = false
|
|
523
535
|
self.detentBeforeSearchFocus = nil
|
|
524
536
|
}
|
|
525
537
|
}
|
|
@@ -552,6 +564,8 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
552
564
|
|
|
553
565
|
private func updateSheetDrag(_ distance: CGFloat) {
|
|
554
566
|
guard !isAnimatingDismissal, currentDetent == .large else { return }
|
|
567
|
+
dismissKeyboardForSheetDrag()
|
|
568
|
+
|
|
555
569
|
let adjustedDistance = max(0, distance * 0.7)
|
|
556
570
|
sheetContainerView.layer.removeAllAnimations()
|
|
557
571
|
sheetContainerView.transform = CGAffineTransform(translationX: 0, y: adjustedDistance)
|
|
@@ -566,9 +580,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
566
580
|
let dismissalThreshold = max(sheetContainerView.bounds.height * 0.5, 1)
|
|
567
581
|
let shouldDismiss = distance >= dismissalThreshold || velocity >= 1400
|
|
568
582
|
if shouldDismiss {
|
|
569
|
-
|
|
570
|
-
self?.onDismiss?()
|
|
571
|
-
}
|
|
583
|
+
requestDismiss()
|
|
572
584
|
return
|
|
573
585
|
}
|
|
574
586
|
|
|
@@ -579,8 +591,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
579
591
|
initialSpringVelocity: 0.1,
|
|
580
592
|
options: [.curveEaseOut]
|
|
581
593
|
) {
|
|
582
|
-
self.
|
|
594
|
+
self.applyDetentLayout(.large)
|
|
583
595
|
self.backdropView.alpha = Layout.backdropAlpha
|
|
596
|
+
self.view.layoutIfNeeded()
|
|
584
597
|
}
|
|
585
598
|
}
|
|
586
599
|
|
|
@@ -623,17 +636,28 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
623
636
|
sheetContainerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
624
637
|
sheetContainerView.layer.masksToBounds = true
|
|
625
638
|
|
|
639
|
+
grabberHitAreaView.translatesAutoresizingMaskIntoConstraints = false
|
|
640
|
+
grabberHitAreaView.backgroundColor = .clear
|
|
641
|
+
grabberHitAreaView.isAccessibilityElement = true
|
|
642
|
+
grabberHitAreaView.accessibilityLabel = "Dismiss emoji sheet"
|
|
643
|
+
grabberHitAreaView.accessibilityHint = "Double-tap to dismiss."
|
|
644
|
+
grabberHitAreaView.accessibilityTraits = .button
|
|
645
|
+
grabberHitAreaView.addTarget(self, action: #selector(handleGrabberTap), for: .touchUpInside)
|
|
646
|
+
|
|
626
647
|
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
|
627
648
|
grabberView.backgroundColor = theme.handleColor
|
|
628
649
|
grabberView.layer.cornerRadius = Layout.grabberHeight / 2
|
|
650
|
+
grabberView.isUserInteractionEnabled = false
|
|
629
651
|
|
|
630
652
|
contentContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
631
653
|
contentContainerView.backgroundColor = .clear
|
|
632
654
|
|
|
655
|
+
sheetContainerView.accessibilityViewIsModal = true
|
|
633
656
|
view.addSubview(backdropView)
|
|
634
657
|
view.addSubview(sheetContainerView)
|
|
635
658
|
sheetContainerView.addSubview(contentContainerView)
|
|
636
|
-
sheetContainerView.addSubview(
|
|
659
|
+
sheetContainerView.addSubview(grabberHitAreaView)
|
|
660
|
+
grabberHitAreaView.addSubview(grabberView)
|
|
637
661
|
|
|
638
662
|
if gestureEnabled {
|
|
639
663
|
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleSheetPan(_:)))
|
|
@@ -659,11 +683,16 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
659
683
|
sheetContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
660
684
|
sheetBottomConstraint,
|
|
661
685
|
|
|
686
|
+
grabberHitAreaView.topAnchor.constraint(equalTo: sheetContainerView.topAnchor),
|
|
687
|
+
grabberHitAreaView.centerXAnchor.constraint(equalTo: sheetContainerView.centerXAnchor),
|
|
688
|
+
grabberHitAreaView.widthAnchor.constraint(equalToConstant: 96),
|
|
689
|
+
grabberHitAreaView.heightAnchor.constraint(equalToConstant: 24),
|
|
690
|
+
|
|
662
691
|
grabberView.topAnchor.constraint(
|
|
663
|
-
equalTo:
|
|
692
|
+
equalTo: grabberHitAreaView.topAnchor,
|
|
664
693
|
constant: Layout.grabberTopInset
|
|
665
694
|
),
|
|
666
|
-
grabberView.centerXAnchor.constraint(equalTo:
|
|
695
|
+
grabberView.centerXAnchor.constraint(equalTo: grabberHitAreaView.centerXAnchor),
|
|
667
696
|
grabberView.widthAnchor.constraint(equalToConstant: Layout.grabberWidth),
|
|
668
697
|
grabberView.heightAnchor.constraint(equalToConstant: Layout.grabberHeight),
|
|
669
698
|
|
|
@@ -675,6 +704,15 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
675
704
|
}
|
|
676
705
|
|
|
677
706
|
@objc private func handleBackdropTap() {
|
|
707
|
+
requestDismiss()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
@objc private func handleGrabberTap() {
|
|
711
|
+
requestDismiss()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private func requestDismiss() {
|
|
715
|
+
guard !isAnimatingDismissal else { return }
|
|
678
716
|
dismissSheet { [weak self] in
|
|
679
717
|
self?.onDismiss?()
|
|
680
718
|
}
|
|
@@ -692,6 +730,9 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
692
730
|
|
|
693
731
|
switch gesture.state {
|
|
694
732
|
case .changed:
|
|
733
|
+
if gesture.translation(in: view).y > 0 {
|
|
734
|
+
dismissKeyboardForSheetDrag()
|
|
735
|
+
}
|
|
695
736
|
sheetContainerView.transform = CGAffineTransform(translationX: 0, y: translationY)
|
|
696
737
|
backdropView.alpha = Layout.backdropAlpha * (1 - dismissProgress)
|
|
697
738
|
case .ended, .cancelled:
|
|
@@ -701,9 +742,7 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
701
742
|
velocityY > Layout.dismissVelocityThreshold
|
|
702
743
|
|
|
703
744
|
if shouldDismiss {
|
|
704
|
-
|
|
705
|
-
self?.onDismiss?()
|
|
706
|
-
}
|
|
745
|
+
requestDismiss()
|
|
707
746
|
} else {
|
|
708
747
|
let targetDetent: Detent
|
|
709
748
|
|
|
@@ -727,29 +766,61 @@ private final class SheetViewController: UIViewController, UIGestureRecognizerDe
|
|
|
727
766
|
}
|
|
728
767
|
}
|
|
729
768
|
|
|
769
|
+
private func dismissKeyboardForSheetDrag() {
|
|
770
|
+
view.endEditing(true)
|
|
771
|
+
}
|
|
772
|
+
|
|
730
773
|
private var dismissalTranslation: CGFloat {
|
|
731
774
|
sheetContainerView.bounds.height + view.safeAreaInsets.bottom + 24
|
|
732
775
|
}
|
|
733
776
|
|
|
734
|
-
private func
|
|
735
|
-
|
|
777
|
+
private func applyDetentLayout(_ detent: Detent, keyboardOverlap overlap: CGFloat? = nil) {
|
|
778
|
+
let overlap = overlap ?? keyboardOverlap
|
|
779
|
+
sheetBottomConstraint?.constant = bottomConstraintConstant(for: detent, keyboardOverlap: overlap)
|
|
780
|
+
sheetContainerView.transform = transform(for: detent, keyboardOverlap: overlap)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private func transform(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGAffineTransform {
|
|
784
|
+
CGAffineTransform(translationX: 0, y: translation(for: detent, keyboardOverlap: overlap))
|
|
736
785
|
}
|
|
737
786
|
|
|
738
787
|
private func transformForDismissal() -> CGAffineTransform {
|
|
739
788
|
CGAffineTransform(translationX: 0, y: dismissalTranslation)
|
|
740
789
|
}
|
|
741
790
|
|
|
742
|
-
private func
|
|
791
|
+
private func bottomConstraintConstant(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
|
|
792
|
+
let overlap = overlap ?? keyboardOverlap
|
|
793
|
+
guard overlap > 0 else { return 0 }
|
|
794
|
+
return -overlap - translation(for: detent, keyboardOverlap: overlap)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private func translation(for detent: Detent, keyboardOverlap overlap: CGFloat? = nil) -> CGFloat {
|
|
798
|
+
let fullHeight = max(0, view.bounds.height - view.safeAreaInsets.top - Layout.sheetTopInset)
|
|
799
|
+
let availableHeight = max(0, fullHeight - (overlap ?? keyboardOverlap))
|
|
800
|
+
let visibleHeight = visibleHeight(for: detent, availableHeight: availableHeight, fullHeight: fullHeight)
|
|
801
|
+
return max(0, availableHeight - visibleHeight)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private func visibleHeight(for detent: Detent, availableHeight: CGFloat, fullHeight: CGFloat) -> CGFloat {
|
|
743
805
|
switch detent {
|
|
744
806
|
case .large:
|
|
745
|
-
|
|
746
|
-
case .medium:
|
|
747
|
-
let containerHeight = max(sheetContainerView.bounds.height, 0)
|
|
748
|
-
let visibleHeight = max(
|
|
807
|
+
let mediumVisibleHeight = max(
|
|
749
808
|
Layout.minimumMediumVisibleHeight,
|
|
750
|
-
|
|
809
|
+
fullHeight * mediumDetentRatio
|
|
810
|
+
)
|
|
811
|
+
return min(
|
|
812
|
+
availableHeight,
|
|
813
|
+
max(mediumVisibleHeight, fullHeight * largeDetentRatio)
|
|
814
|
+
)
|
|
815
|
+
case .medium:
|
|
816
|
+
let visibleHeight = min(
|
|
817
|
+
availableHeight,
|
|
818
|
+
max(
|
|
819
|
+
Layout.minimumMediumVisibleHeight,
|
|
820
|
+
fullHeight * mediumDetentRatio
|
|
821
|
+
)
|
|
751
822
|
)
|
|
752
|
-
return max(0,
|
|
823
|
+
return max(0, visibleHeight)
|
|
753
824
|
}
|
|
754
825
|
}
|
|
755
826
|
}
|
|
@@ -91,6 +91,7 @@ class EmojiSheetUIView: UIView,
|
|
|
91
91
|
static let floatingBarHorizontalInset: CGFloat = 16
|
|
92
92
|
static let floatingBarBottomInset: CGFloat = 8
|
|
93
93
|
static let floatingBarCornerRadius: CGFloat = 22
|
|
94
|
+
static let keyboardBottomSpacing: CGFloat = 16
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
weak var delegate: EmojiSheetUIViewDelegate?
|
|
@@ -141,6 +142,7 @@ class EmojiSheetUIView: UIView,
|
|
|
141
142
|
private var allSections: [EmojiSection] = []
|
|
142
143
|
private var filteredSections: [EmojiSection] = []
|
|
143
144
|
private var frequentlyUsedSection: EmojiSection?
|
|
145
|
+
// Values are normalized once at load time so search scoring only compares strings.
|
|
144
146
|
private var localizedKeywords: [String: [String]] = [:]
|
|
145
147
|
private var currentSearchText: String?
|
|
146
148
|
private var loadTask: Task<Void, Never>?
|
|
@@ -148,6 +150,8 @@ class EmojiSheetUIView: UIView,
|
|
|
148
150
|
// Search work is cancellable. The generation counter remains as a lightweight
|
|
149
151
|
// secondary guard so stale results never apply after a newer query wins.
|
|
150
152
|
private var searchGeneration: Int = 0
|
|
153
|
+
private var baseGridBottomInset: CGFloat = 0
|
|
154
|
+
private var keyboardGridBottomInset: CGFloat = 0
|
|
151
155
|
|
|
152
156
|
private let searchBar = EmojiSearchBar()
|
|
153
157
|
private let categoryStrip = EmojiCategoryStrip()
|
|
@@ -181,6 +185,7 @@ class EmojiSheetUIView: UIView,
|
|
|
181
185
|
"symbols": "Symbols",
|
|
182
186
|
"flags": "Flags",
|
|
183
187
|
"frequently_used": "Frequently Used",
|
|
188
|
+
"search_results": "Search Results",
|
|
184
189
|
]
|
|
185
190
|
|
|
186
191
|
// MARK: - Init
|
|
@@ -197,6 +202,7 @@ class EmojiSheetUIView: UIView,
|
|
|
197
202
|
deinit {
|
|
198
203
|
loadTask?.cancel()
|
|
199
204
|
searchTask?.cancel()
|
|
205
|
+
NotificationCenter.default.removeObserver(self)
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
// MARK: - Setup
|
|
@@ -221,6 +227,7 @@ class EmojiSheetUIView: UIView,
|
|
|
221
227
|
categoryStrip.delegate = self
|
|
222
228
|
gridView.delegate = self
|
|
223
229
|
|
|
230
|
+
registerKeyboardNotifications()
|
|
224
231
|
configureLayout()
|
|
225
232
|
applyLayoutDirection()
|
|
226
233
|
}
|
|
@@ -353,7 +360,8 @@ class EmojiSheetUIView: UIView,
|
|
|
353
360
|
// Bottom content inset so grid content scrolls above the floating bar
|
|
354
361
|
let floatingBarTotalHeight = LayoutConstants.categoryStripHeight
|
|
355
362
|
+ LayoutConstants.floatingBarBottomInset * 2
|
|
356
|
-
|
|
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.1.0",
|
|
4
4
|
"description": "A fully native emoji picker bottom sheet for React Native. Built with Swift and Kotlin for maximum performance. Features search with multilingual keywords, skin tones, frequently used tracking, theming, and configurable layout.",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|