feat: Improve send button animation in MessageInputBar for smoother UX
This commit is contained in:
@@ -36,10 +36,12 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -389,10 +391,10 @@ private fun MessageBubble(
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель ввода сообщения со стеклянным эффектом (Glass Morphism)
|
||||
* Все иконки внутри одного стеклянного инпута
|
||||
* + Плавная анимация самолетика
|
||||
* + Эмодзи пикер
|
||||
* Панель ввода сообщения 1:1 как в React Native
|
||||
* - Слева: круглая кнопка Attach (скрепка)
|
||||
* - Посередине: стеклянный инпут с текстом + справа emoji + send
|
||||
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -405,130 +407,122 @@ private fun MessageInputBar(
|
||||
textColor: Color,
|
||||
placeholderColor: Color
|
||||
) {
|
||||
// Состояние эмодзи пикера
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Цвета для glass morphism эффекта
|
||||
val glassBackground = if (isDarkTheme)
|
||||
Color(0xFF1A1A1A).copy(alpha = 0.95f)
|
||||
else
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.95f)
|
||||
// Цвета как в RN
|
||||
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
|
||||
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
|
||||
|
||||
val inputGlass = if (isDarkTheme)
|
||||
Color(0xFF2C2C2E).copy(alpha = 0.8f)
|
||||
else
|
||||
Color(0xFFF2F2F7).copy(alpha = 0.9f)
|
||||
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
|
||||
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
|
||||
|
||||
val inputBorder = if (isDarkTheme)
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.12f)
|
||||
else
|
||||
Color(0xFF000000).copy(alpha = 0.06f)
|
||||
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
||||
|
||||
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
// === Анимации как в React Native ===
|
||||
val canSend = value.isNotBlank()
|
||||
|
||||
// Анимация для кнопки отправки
|
||||
val sendButtonVisible = value.isNotBlank()
|
||||
val sendButtonScale by animateFloatAsState(
|
||||
targetValue = if (sendButtonVisible) 1f else 0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
// Easing functions
|
||||
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
|
||||
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||
|
||||
// Send button animations
|
||||
val sendOpacity by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(200, easing = smoothEasing),
|
||||
label = "sendOpacity"
|
||||
)
|
||||
val sendScale by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0.5f,
|
||||
animationSpec = tween(220, easing = backEasing),
|
||||
label = "sendScale"
|
||||
)
|
||||
val sendButtonRotation by animateFloatAsState(
|
||||
targetValue = if (sendButtonVisible) 0f else -90f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
),
|
||||
label = "sendRotation"
|
||||
|
||||
// Mic button animations
|
||||
val micOpacity by animateFloatAsState(
|
||||
targetValue = if (canSend) 0f else 1f,
|
||||
animationSpec = if (canSend) tween(150, easing = smoothEasing) else tween(200, delayMillis = 100, easing = smoothEasing),
|
||||
label = "micOpacity"
|
||||
)
|
||||
val sendButtonAlpha by animateFloatAsState(
|
||||
targetValue = if (sendButtonVisible) 1f else 0f,
|
||||
animationSpec = tween(200),
|
||||
label = "sendAlpha"
|
||||
val micTranslateX by animateFloatAsState(
|
||||
targetValue = if (canSend) 80f else 0f,
|
||||
animationSpec = if (canSend) tween(250, easing = smoothEasing) else tween(250, delayMillis = 80, easing = smoothEasing),
|
||||
label = "micTranslateX"
|
||||
)
|
||||
|
||||
// Emoji button animation (сдвигается влево когда появляется send)
|
||||
val emojiTranslateX by animateFloatAsState(
|
||||
targetValue = if (canSend) -50f else 0f,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "emojiTranslateX"
|
||||
)
|
||||
|
||||
// Input margin animation (расширяется когда текст есть)
|
||||
val inputEndMargin by animateDpAsState(
|
||||
targetValue = if (canSend) 0.dp else 56.dp,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "inputEndMargin"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding() // Только инпут поднимается с клавиатурой
|
||||
.imePadding()
|
||||
) {
|
||||
// Основной контейнер инпута
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(glassBackground)
|
||||
.background(panelBackground)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
// Единый стеклянный контейнер для всего инпута
|
||||
// === ATTACH BUTTON (круг слева) ===
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable { /* TODO: Attach */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = circleIcon,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// === GLASS INPUT (расширяется вправо) ===
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 44.dp, max = 120.dp)
|
||||
.padding(end = inputEndMargin)
|
||||
.heightIn(min = 48.dp, max = 120.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(inputGlass)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = inputBorder,
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
// Блик сверху для стеклянного эффекта
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = if (isDarkTheme) 0.05f else 0.3f),
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = if (isDarkTheme) 0.02f else 0.01f)
|
||||
),
|
||||
startY = 0f,
|
||||
endY = 80f
|
||||
),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
.background(glassBackground)
|
||||
.border(1.dp, glassBorder, RoundedCornerShape(22.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Кнопка смайликов (слева внутри инпута)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
keyboardController?.hide()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = if (showEmojiPicker) PrimaryBlue else iconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Текстовое поле
|
||||
// Text field
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable {
|
||||
showEmojiPicker = false
|
||||
},
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
BasicTextField(
|
||||
@@ -539,19 +533,10 @@ private fun MessageInputBar(
|
||||
fontSize = 16.sp
|
||||
),
|
||||
cursorBrush = SolidColor(PrimaryBlue),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
showEmojiPicker = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 5,
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = "Message",
|
||||
@@ -564,76 +549,88 @@ private fun MessageInputBar(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопка прикрепления (справа внутри инпута)
|
||||
IconButton(
|
||||
onClick = { /* TODO: Attachment picker */ },
|
||||
modifier = Modifier.size(36.dp)
|
||||
}
|
||||
|
||||
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута ===
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 6.dp, bottom = 4.dp)
|
||||
) {
|
||||
// Emoji button (сдвигается влево при send)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { translationX = emojiTranslateX }
|
||||
.size(40.dp)
|
||||
.clickable {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
keyboardController?.hide()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопка камеры (справа внутри инпута)
|
||||
IconButton(
|
||||
onClick = { /* TODO: Camera */ },
|
||||
modifier = Modifier.size(36.dp)
|
||||
// Send button (появляется поверх emoji)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
scaleX = sendScale
|
||||
scaleY = sendScale
|
||||
alpha = sendOpacity
|
||||
}
|
||||
.size(width = 52.dp, height = 34.dp)
|
||||
.clip(RoundedCornerShape(17.dp))
|
||||
.background(PrimaryBlue)
|
||||
.clickable(enabled = canSend) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = "Camera",
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Кнопка отправки с плавной анимацией
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = sendButtonScale
|
||||
scaleY = sendButtonScale
|
||||
rotationZ = sendButtonRotation
|
||||
alpha = sendButtonAlpha
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
PrimaryBlue,
|
||||
Color(0xFF5B8DEF)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(enabled = sendButtonVisible) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.graphicsLayer {
|
||||
// Дополнительная микроанимация иконки
|
||||
translationX = 2.dp.toPx()
|
||||
translationY = -1.dp.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) ===
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 14.dp, bottom = 8.dp)
|
||||
.graphicsLayer {
|
||||
translationX = micTranslateX
|
||||
alpha = micOpacity
|
||||
}
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable(enabled = !canSend) { /* TODO: Voice */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = "Voice",
|
||||
tint = circleIcon,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Эмодзи пикер (показывается под инпутом, заменяя клавиатуру)
|
||||
// Apple Emoji Picker с PNG изображениями
|
||||
AnimatedVisibility(
|
||||
visible = showEmojiPicker,
|
||||
enter = expandVertically(
|
||||
@@ -648,7 +645,7 @@ private fun MessageInputBar(
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(100))
|
||||
) {
|
||||
EmojiPickerPanel(
|
||||
AppleEmojiPickerPanel(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onEmojiSelected = { emoji ->
|
||||
onValueChange(value + emoji)
|
||||
@@ -663,101 +660,3 @@ private fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель выбора эмодзи с Apple-стиль эмодзи
|
||||
*/
|
||||
@Composable
|
||||
private fun EmojiPickerPanel(
|
||||
isDarkTheme: Boolean,
|
||||
onEmojiSelected: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||
val categoryBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
// Категории эмодзи
|
||||
val emojiCategories = remember {
|
||||
listOf(
|
||||
"😀" to listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "☺️", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤", "😴", "😷"),
|
||||
"❤️" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️", "💌", "💋", "👄", "👅", "🫦"),
|
||||
"👋" to listOf("👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪"),
|
||||
"🔥" to listOf("🔥", "⭐", "🌟", "✨", "⚡", "💥", "💫", "🎉", "🎊", "🎈", "🎁", "🏆", "🥇", "🥈", "🥉", "🎯", "🎪", "🎭", "🎨", "🎬", "🎤", "🎧", "🎵", "🎶", "🎹", "🎸", "🎺", "🎷", "🪘", "🥁"),
|
||||
"🐱" to listOf("🐱", "🐶", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔", "🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄"),
|
||||
"🍎" to listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔"),
|
||||
"⚽" to listOf("⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷"),
|
||||
"🚗" to listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍️", "🛵", "🚲", "🛴", "🚨", "🚔", "🚍", "🚘", "🚖", "✈️", "🛫", "🛬", "🚀", "🛸", "🚁", "⛵")
|
||||
)
|
||||
}
|
||||
|
||||
var selectedCategory by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(280.dp)
|
||||
.background(panelBackground)
|
||||
) {
|
||||
// Категории вверху
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(categoryBackground)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
emojiCategories.forEachIndexed { index, (emoji, _) ->
|
||||
val isSelected = selectedCategory == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
|
||||
)
|
||||
.clickable { selectedCategory = index },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
// Сетка эмодзи
|
||||
val emojis = emojiCategories[selectedCategory].second
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(8),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(emojis.size) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onEmojiSelected(emojis[index]) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = emojis[index],
|
||||
fontSize = 28.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
|
||||
/**
|
||||
* Apple Emoji Picker с PNG изображениями
|
||||
* Категории и эмодзи как в React Native версии
|
||||
*/
|
||||
|
||||
// Категории эмодзи
|
||||
data class EmojiCategory(
|
||||
val key: String,
|
||||
val label: String // unified код для иконки категории
|
||||
)
|
||||
|
||||
// Данные одного эмодзи
|
||||
data class EmojiItem(
|
||||
val unified: String,
|
||||
val shortName: String,
|
||||
val category: String
|
||||
)
|
||||
|
||||
// Категории как в RN
|
||||
val EMOJI_CATEGORIES = listOf(
|
||||
EmojiCategory("Smileys & Emotion", "1f600"), // 😀
|
||||
EmojiCategory("People & Body", "1f44b"), // 👋
|
||||
EmojiCategory("Animals & Nature", "1f431"), // 🐱
|
||||
EmojiCategory("Food & Drink", "1f34e"), // 🍎
|
||||
EmojiCategory("Travel & Places", "2708-fe0f"), // ✈️
|
||||
EmojiCategory("Activities", "26bd"), // ⚽
|
||||
EmojiCategory("Objects", "1f4a1"), // 💡
|
||||
EmojiCategory("Symbols", "2764-fe0f"), // ❤️
|
||||
EmojiCategory("Flags", "1f3f3-fe0f") // 🏳️
|
||||
)
|
||||
|
||||
// Эмодзи по категориям (основные)
|
||||
val EMOJIS_BY_CATEGORY = mapOf(
|
||||
"Smileys & Emotion" to listOf(
|
||||
"1f600", "1f603", "1f604", "1f601", "1f606", "1f605", "1f923", "1f602",
|
||||
"1f642", "1f643", "1f609", "1f60a", "1f607", "1f970", "1f60d", "1f929",
|
||||
"1f618", "1f617", "263a-fe0f", "1f61a", "1f619", "1f972", "1f60b", "1f61b",
|
||||
"1f61c", "1f92a", "1f61d", "1f911", "1f917", "1f92d", "1f92b", "1f914",
|
||||
"1f910", "1f928", "1f610", "1f611", "1f636", "1f60f", "1f612", "1f644",
|
||||
"1f62c", "1f925", "1f60c", "1f614", "1f62a", "1f924", "1f634", "1f637",
|
||||
"1f912", "1f915", "1f922", "1f92e", "1f927", "1f975", "1f976", "1f974",
|
||||
"1f635", "1f92f", "1f920", "1f973", "1f978", "1f60e", "1f913", "1f9d0"
|
||||
),
|
||||
"People & Body" to listOf(
|
||||
"1f44b", "1f91a", "1f590-fe0f", "270b", "1f596", "1f44c", "1f90c", "1f90f",
|
||||
"270c-fe0f", "1f91e", "1f91f", "1f918", "1f919", "1f448", "1f449", "1f446",
|
||||
"1f595", "1f447", "261d-fe0f", "1f44d", "1f44e", "270a", "1f44a", "1f91b",
|
||||
"1f91c", "1f44f", "1f64c", "1f450", "1f932", "1f91d", "1f64f", "270d-fe0f",
|
||||
"1f485", "1f933", "1f4aa", "1f9be", "1f9bf", "1f9b5", "1f9b6", "1f442",
|
||||
"1f9bb", "1f443", "1f9e0", "1fac0", "1fac1", "1f9b7", "1f9b4", "1f440"
|
||||
),
|
||||
"Animals & Nature" to listOf(
|
||||
"1f435", "1f412", "1f98d", "1f9a7", "1f436", "1f415", "1f9ae", "1f415-200d-1f9ba",
|
||||
"1f429", "1f43a", "1f98a", "1f99d", "1f431", "1f408", "1f408-200d-2b1b", "1f981",
|
||||
"1f42f", "1f405", "1f406", "1f434", "1f40e", "1f984", "1f993", "1f98c",
|
||||
"1f9ac", "1f42e", "1f402", "1f403", "1f404", "1f437", "1f416", "1f417",
|
||||
"1f43d", "1f40f", "1f411", "1f410", "1f42a", "1f42b", "1f999", "1f992"
|
||||
),
|
||||
"Food & Drink" to listOf(
|
||||
"1f347", "1f348", "1f349", "1f34a", "1f34b", "1f34c", "1f34d", "1f96d",
|
||||
"1f34e", "1f34f", "1f350", "1f351", "1f352", "1f353", "1fad0", "1f95d",
|
||||
"1f345", "1fad2", "1f965", "1f951", "1f346", "1f954", "1f955", "1f33d",
|
||||
"1f336-fe0f", "1fad1", "1f952", "1f96c", "1f966", "1f9c4", "1f9c5", "1f344",
|
||||
"1f95c", "1f330", "1f35e", "1f950", "1f956", "1fad3", "1f968", "1f96f"
|
||||
),
|
||||
"Travel & Places" to listOf(
|
||||
"1f697", "1f695", "1f699", "1f68c", "1f68e", "1f3ce-fe0f", "1f693", "1f691",
|
||||
"1f692", "1f690", "1f69b", "1f69c", "1f6f5", "1f3cd-fe0f", "1f6b2", "1f6f4",
|
||||
"1f6f9", "1f6fc", "1f68f", "1f6e3-fe0f", "1f6e4-fe0f", "26fd", "1f6a8", "1f6a5",
|
||||
"1f6a6", "1f6d1", "1f6a7", "2708-fe0f", "1f6eb", "1f6ec", "1f6e9-fe0f", "1f4ba",
|
||||
"1f681", "1f69f", "1f6a0", "1f6a1", "1f6f0-fe0f", "1f680", "1f6f8", "1f6f6"
|
||||
),
|
||||
"Activities" to listOf(
|
||||
"26bd", "1f3c0", "1f3c8", "26be", "1f94e", "1f3be", "1f3d0", "1f3c9",
|
||||
"1f94f", "1f3b1", "1f3d3", "1f3f8", "1f3d2", "1f3d1", "1f94d", "1f3cf",
|
||||
"1f945", "26f3", "1f3bf", "1f6f7", "1f3af", "1fa80", "1fa81", "1f3b3",
|
||||
"1f3ae", "1f3b2", "1f9e9", "1f3ad", "1f3a8", "1f9f5", "1f9f6", "1f3bc",
|
||||
"1f3b5", "1f3b6", "1f399-fe0f", "1f39a-fe0f", "1f39b-fe0f", "1f3a4", "1f3a7", "1f4fb"
|
||||
),
|
||||
"Objects" to listOf(
|
||||
"1f4a1", "1f526", "1f3ee", "1fa94", "1f4d4", "1f4d5", "1f4d6", "1f4d7",
|
||||
"1f4d8", "1f4d9", "1f4da", "1f4d3", "1f4d2", "1f4c3", "1f4dc", "1f4c4",
|
||||
"1f4f0", "1f5de-fe0f", "1f4d1", "1f516", "1f3f7-fe0f", "1f4b0", "1fa99", "1f4b4",
|
||||
"1f4b5", "1f4b6", "1f4b7", "1f4b8", "1f4b3", "1f9fe", "1f4b9", "1f4e7",
|
||||
"1f4e8", "1f4e9", "1f4e4", "1f4e5", "1f4e6", "1f4eb", "1f4ea", "1f4ec"
|
||||
),
|
||||
"Symbols" to listOf(
|
||||
"2764-fe0f", "1f9e1", "1f49b", "1f49a", "1f499", "1f49c", "1f5a4", "1f90d",
|
||||
"1f90e", "1f494", "2763-fe0f", "1f495", "1f49e", "1f493", "1f497", "1f496",
|
||||
"1f498", "1f49d", "1f49f", "2665-fe0f", "1f4af", "1f4a2", "1f4a5", "1f4ab",
|
||||
"1f4a6", "1f4a8", "1f573-fe0f", "1f4ac", "1f441-fe0f-200d-1f5e8-fe0f", "1f5e8-fe0f",
|
||||
"1f5ef-fe0f", "1f4ad", "1f4a4", "1f44b", "1f91a", "1f590-fe0f", "270b", "1f596"
|
||||
),
|
||||
"Flags" to listOf(
|
||||
"1f3f3-fe0f", "1f3f4", "1f3c1", "1f6a9", "1f38c", "1f3f4-200d-2620-fe0f",
|
||||
"1f1e6-1f1e8", "1f1e6-1f1e9", "1f1e6-1f1ea", "1f1e6-1f1eb", "1f1e6-1f1ec",
|
||||
"1f1e6-1f1ee", "1f1e6-1f1f1", "1f1e6-1f1f2", "1f1e6-1f1f4", "1f1e6-1f1f6",
|
||||
"1f1e6-1f1f7", "1f1e6-1f1f8", "1f1e6-1f1f9", "1f1e6-1f1fa", "1f1e6-1f1fc",
|
||||
"1f1e6-1f1fd", "1f1e6-1f1ff", "1f1e7-1f1e6", "1f1e7-1f1e7", "1f1e7-1f1e9"
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Конвертирует unified код в emoji символ
|
||||
*/
|
||||
fun unifiedToEmoji(unified: String): String {
|
||||
return unified.split("-").mapNotNull { code ->
|
||||
try {
|
||||
val codePoint = code.toInt(16)
|
||||
if (Character.isValidCodePoint(codePoint)) {
|
||||
String(Character.toChars(codePoint))
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.joinToString("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка эмодзи с PNG изображением и анимацией нажатия
|
||||
*/
|
||||
@Composable
|
||||
fun EmojiButton(
|
||||
unified: String,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.85f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessHigh
|
||||
),
|
||||
label = "emojiScale"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.scale(scale)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
onClick(unifiedToEmoji(unified))
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data("file:///android_asset/emoji/${unified.lowercase()}.png")
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка категории с PNG изображением
|
||||
*/
|
||||
@Composable
|
||||
fun CategoryButton(
|
||||
category: EmojiCategory,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.9f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessHigh
|
||||
),
|
||||
label = "categoryScale"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.scale(scale)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data("file:///android_asset/emoji/${category.label.lowercase()}.png")
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
contentDescription = category.key,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Emoji Picker Panel
|
||||
*/
|
||||
@Composable
|
||||
fun AppleEmojiPickerPanel(
|
||||
isDarkTheme: Boolean,
|
||||
onEmojiSelected: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
|
||||
|
||||
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF8F8FA)
|
||||
val categoryBackground = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFFFFFFF)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
|
||||
|
||||
val currentEmojis = EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(panelBackground)
|
||||
) {
|
||||
// Категории сверху
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(categoryBackground)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
EMOJI_CATEGORIES.forEach { category ->
|
||||
CategoryButton(
|
||||
category = category,
|
||||
isSelected = selectedCategory == category,
|
||||
onClick = { selectedCategory = category }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
// Сетка эмодзи
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(9),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(currentEmojis) { unified ->
|
||||
EmojiButton(
|
||||
unified = unified,
|
||||
onClick = { emoji ->
|
||||
onEmojiSelected(emoji)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user