feat: Add AppleEmojiTextField component for enhanced emoji input with PNG support
This commit is contained in:
@@ -42,6 +42,7 @@ import com.rosetta.messenger.network.SearchUser
|
|||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
||||||
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -518,35 +519,21 @@ private fun MessageInputBar(
|
|||||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Text field
|
// Apple Emoji Text Field (с PNG эмодзи)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
AppleEmojiTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
textColor = textColor,
|
||||||
color = textColor,
|
textSize = 16f,
|
||||||
fontSize = 16.sp
|
hint = "Message",
|
||||||
),
|
hintColor = placeholderColor.copy(alpha = 0.6f),
|
||||||
cursorBrush = SolidColor(PrimaryBlue),
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxLines = 5,
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Message",
|
|
||||||
color = placeholderColor.copy(alpha = 0.6f),
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.rosetta.messenger.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.text.style.ImageSpan
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.LruCache
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple Emoji EditText - кастомный EditText с PNG эмодзи
|
||||||
|
* Заменяет системные эмодзи на Apple PNG изображения из assets
|
||||||
|
*/
|
||||||
|
class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = android.R.attr.editTextStyle
|
||||||
|
) : EditText(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
var onTextChange: ((String) -> Unit)? = null
|
||||||
|
private var isUpdating = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Regex для эмодзи
|
||||||
|
private val EMOJI_PATTERN = Pattern.compile(
|
||||||
|
"[\\x{1F600}-\\x{1F64F}]|" + // Emoticons
|
||||||
|
"[\\x{1F300}-\\x{1F5FF}]|" + // Misc Symbols and Pictographs
|
||||||
|
"[\\x{1F680}-\\x{1F6FF}]|" + // Transport and Map
|
||||||
|
"[\\x{1F1E0}-\\x{1F1FF}]|" + // Flags
|
||||||
|
"[\\x{2600}-\\x{26FF}]|" + // Misc symbols
|
||||||
|
"[\\x{2700}-\\x{27BF}]|" + // Dingbats
|
||||||
|
"[\\x{FE00}-\\x{FE0F}]|" + // Variation Selectors
|
||||||
|
"[\\x{1F900}-\\x{1F9FF}]|" + // Supplemental Symbols
|
||||||
|
"[\\x{1FA00}-\\x{1FA6F}]|" + // Chess Symbols
|
||||||
|
"[\\x{1FA70}-\\x{1FAFF}]|" + // Symbols Extended-A
|
||||||
|
"[\\x{231A}-\\x{231B}]|" + // Watch, Hourglass
|
||||||
|
"[\\x{23E9}-\\x{23F3}]|" + // Media controls
|
||||||
|
"[\\x{23F8}-\\x{23FA}]|" + // Media controls
|
||||||
|
"[\\x{25AA}-\\x{25AB}]|" + // Squares
|
||||||
|
"[\\x{25B6}]|[\\x{25C0}]|" + // Play buttons
|
||||||
|
"[\\x{25FB}-\\x{25FE}]|" + // Squares
|
||||||
|
"[\\x{2614}-\\x{2615}]|" + // Umbrella, Hot beverage
|
||||||
|
"[\\x{2648}-\\x{2653}]|" + // Zodiac
|
||||||
|
"[\\x{267F}]|[\\x{2693}]|" + // Wheelchair, Anchor
|
||||||
|
"[\\x{26A1}]|[\\x{26AA}-\\x{26AB}]|" +
|
||||||
|
"[\\x{26BD}-\\x{26BE}]|" + // Sports
|
||||||
|
"[\\x{26C4}-\\x{26C5}]|" + // Weather
|
||||||
|
"[\\x{26CE}]|[\\x{26D4}]|" +
|
||||||
|
"[\\x{26EA}]|[\\x{26F2}-\\x{26F3}]|" +
|
||||||
|
"[\\x{26F5}]|[\\x{26FA}]|[\\x{26FD}]|" +
|
||||||
|
"[\\x{2702}]|[\\x{2705}]|" +
|
||||||
|
"[\\x{2708}-\\x{270D}]|[\\x{270F}]|" +
|
||||||
|
"[\\x{2712}]|[\\x{2714}]|[\\x{2716}]|" +
|
||||||
|
"[\\x{271D}]|[\\x{2721}]|" +
|
||||||
|
"[\\x{2728}]|[\\x{2733}-\\x{2734}]|" +
|
||||||
|
"[\\x{2744}]|[\\x{2747}]|" +
|
||||||
|
"[\\x{274C}]|[\\x{274E}]|" +
|
||||||
|
"[\\x{2753}-\\x{2755}]|[\\x{2757}]|" +
|
||||||
|
"[\\x{2763}-\\x{2764}]|" +
|
||||||
|
"[\\x{2795}-\\x{2797}]|[\\x{27A1}]|" +
|
||||||
|
"[\\x{27B0}]|[\\x{27BF}]|" +
|
||||||
|
"[\\x{2934}-\\x{2935}]|" +
|
||||||
|
"[\\x{2B05}-\\x{2B07}]|" +
|
||||||
|
"[\\x{2B1B}-\\x{2B1C}]|" +
|
||||||
|
"[\\x{2B50}]|[\\x{2B55}]|" +
|
||||||
|
"[\\x{3030}]|[\\x{303D}]|" +
|
||||||
|
"[\\x{3297}]|[\\x{3299}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кэш для bitmap и drawable
|
||||||
|
private val bitmapCache = LruCache<String, Bitmap>(500)
|
||||||
|
private val drawableCache = LruCache<String, BitmapDrawable>(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Настраиваем EditText
|
||||||
|
background = null
|
||||||
|
setPadding(0, 0, 0, 0)
|
||||||
|
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||||||
|
isSingleLine = false
|
||||||
|
maxLines = 5
|
||||||
|
imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||||
|
|
||||||
|
addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
if (!isUpdating && s != null) {
|
||||||
|
replaceEmojisWithImages(s)
|
||||||
|
// Отправляем plain text без spans
|
||||||
|
onTextChange?.invoke(s.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTextWithEmojis(newText: String) {
|
||||||
|
if (newText == text.toString()) return
|
||||||
|
isUpdating = true
|
||||||
|
setText(newText)
|
||||||
|
isUpdating = false
|
||||||
|
replaceEmojisWithImages(editableText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceEmojisWithImages(editable: Editable) {
|
||||||
|
if (isUpdating) return
|
||||||
|
isUpdating = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
val textStr = editable.toString()
|
||||||
|
val matcher = EMOJI_PATTERN.matcher(textStr)
|
||||||
|
val cursorPosition = selectionStart
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
val emoji = matcher.group()
|
||||||
|
val start = matcher.start()
|
||||||
|
val end = matcher.end()
|
||||||
|
|
||||||
|
// Проверяем, есть ли уже ImageSpan
|
||||||
|
val existingSpans = editable.getSpans(start, end, ImageSpan::class.java)
|
||||||
|
if (existingSpans.isNotEmpty()) continue
|
||||||
|
|
||||||
|
val unified = emojiToUnified(emoji)
|
||||||
|
var drawable = drawableCache.get(unified)
|
||||||
|
|
||||||
|
if (drawable == null) {
|
||||||
|
var bitmap = bitmapCache.get(unified)
|
||||||
|
if (bitmap == null) {
|
||||||
|
bitmap = loadFromAssets(unified)
|
||||||
|
if (bitmap != null) {
|
||||||
|
bitmapCache.put(unified, bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap != null) {
|
||||||
|
drawable = BitmapDrawable(getContext().resources, bitmap)
|
||||||
|
val size = (textSize * 1.15).toInt()
|
||||||
|
drawable.setBounds(0, 0, size, size)
|
||||||
|
drawableCache.put(unified, drawable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawable != null && start < editable.length && end <= editable.length) {
|
||||||
|
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_CENTER)
|
||||||
|
editable.setSpan(imageSpan, start, end, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorPosition >= 0 && cursorPosition <= editable.length) {
|
||||||
|
setSelection(cursorPosition)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUpdating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromAssets(unified: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val inputStream = getContext().assets.open("emoji/$unified.png")
|
||||||
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream.close()
|
||||||
|
bitmap
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emojiToUnified(emoji: String): String {
|
||||||
|
return emoji.codePoints()
|
||||||
|
.filter { it != 0xFE0F }
|
||||||
|
.mapToObj { String.format("%04x", it) }
|
||||||
|
.toList()
|
||||||
|
.joinToString("-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose обёртка для AppleEmojiEditText
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppleEmojiTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White,
|
||||||
|
textSize: Float = 16f,
|
||||||
|
hint: String = "Message",
|
||||||
|
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
AppleEmojiEditTextView(ctx).apply {
|
||||||
|
setTextColor(textColor.toArgb())
|
||||||
|
setHintTextColor(hintColor.toArgb())
|
||||||
|
setHint(hint)
|
||||||
|
setTextSize(textSize)
|
||||||
|
onTextChange = onValueChange
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view ->
|
||||||
|
if (view.text.toString() != value) {
|
||||||
|
view.setTextWithEmojis(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user