feat: Enhance MessageInputBar with emoji picker and smooth send button animation
This commit is contained in:
@@ -29,6 +29,9 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.imePadding
|
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 com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
@@ -385,6 +388,8 @@ private fun MessageBubble(
|
|||||||
/**
|
/**
|
||||||
* Панель ввода сообщения со стеклянным эффектом (Glass Morphism)
|
* Панель ввода сообщения со стеклянным эффектом (Glass Morphism)
|
||||||
* Все иконки внутри одного стеклянного инпута
|
* Все иконки внутри одного стеклянного инпута
|
||||||
|
* + Плавная анимация самолетика
|
||||||
|
* + Эмодзи пикер
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageInputBar(
|
private fun MessageInputBar(
|
||||||
@@ -396,6 +401,9 @@ private fun MessageInputBar(
|
|||||||
textColor: Color,
|
textColor: Color,
|
||||||
placeholderColor: Color
|
placeholderColor: Color
|
||||||
) {
|
) {
|
||||||
|
// Состояние эмодзи пикера
|
||||||
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Цвета для glass morphism эффекта
|
// Цвета для glass morphism эффекта
|
||||||
val glassBackground = if (isDarkTheme)
|
val glassBackground = if (isDarkTheme)
|
||||||
Color(0xFF1A1A1A).copy(alpha = 0.85f)
|
Color(0xFF1A1A1A).copy(alpha = 0.85f)
|
||||||
@@ -414,155 +422,312 @@ private fun MessageInputBar(
|
|||||||
|
|
||||||
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
|
||||||
// Основной контейнер
|
// Анимация для кнопки отправки
|
||||||
Box(
|
val sendButtonVisible = value.isNotBlank()
|
||||||
modifier = Modifier
|
val sendButtonScale by animateFloatAsState(
|
||||||
.fillMaxWidth()
|
targetValue = if (sendButtonVisible) 1f else 0f,
|
||||||
.background(glassBackground)
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
),
|
||||||
|
label = "sendScale"
|
||||||
|
)
|
||||||
|
val sendButtonRotation by animateFloatAsState(
|
||||||
|
targetValue = if (sendButtonVisible) 0f else -90f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
),
|
||||||
|
label = "sendRotation"
|
||||||
|
)
|
||||||
|
val sendButtonAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (sendButtonVisible) 1f else 0f,
|
||||||
|
animationSpec = tween(200),
|
||||||
|
label = "sendAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(
|
// Эмодзи пикер (показывается над инпутом)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showEmojiPicker,
|
||||||
|
enter = slideInVertically(
|
||||||
|
initialOffsetY = { it },
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow
|
||||||
|
)
|
||||||
|
) + fadeIn(),
|
||||||
|
exit = slideOutVertically(
|
||||||
|
targetOffsetY = { it },
|
||||||
|
animationSpec = tween(200)
|
||||||
|
) + fadeOut()
|
||||||
|
) {
|
||||||
|
EmojiPickerPanel(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onEmojiSelected = { emoji ->
|
||||||
|
onValueChange(value + emoji)
|
||||||
|
},
|
||||||
|
onClose = { showEmojiPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основной контейнер
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.background(glassBackground)
|
||||||
.padding(bottom = 4.dp)
|
|
||||||
.navigationBarsPadding(),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
) {
|
||||||
// Единый стеклянный контейнер для всего инпута
|
Row(
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.heightIn(min = 44.dp, max = 120.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.padding(bottom = 4.dp)
|
||||||
.background(inputGlass)
|
.navigationBarsPadding(),
|
||||||
.border(
|
verticalAlignment = Alignment.Bottom
|
||||||
width = 1.dp,
|
|
||||||
color = inputBorder,
|
|
||||||
shape = RoundedCornerShape(22.dp)
|
|
||||||
)
|
|
||||||
// Блик сверху для стеклянного эффекта
|
|
||||||
.background(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = if (isDarkTheme) 0.06f else 0.4f),
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = if (isDarkTheme) 0.03f else 0.02f)
|
|
||||||
),
|
|
||||||
startY = 0f,
|
|
||||||
endY = 80f
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(22.dp)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
// Единый стеклянный контейнер для всего инпута
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.heightIn(min = 44.dp, max = 120.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
.clip(RoundedCornerShape(22.dp))
|
||||||
) {
|
.background(inputGlass)
|
||||||
// Кнопка смайликов (слева внутри инпута)
|
.border(
|
||||||
IconButton(
|
width = 1.dp,
|
||||||
onClick = { /* TODO: Emoji picker */ },
|
color = inputBorder,
|
||||||
modifier = Modifier.size(36.dp)
|
shape = RoundedCornerShape(22.dp)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.EmojiEmotions,
|
|
||||||
contentDescription = "Emoji",
|
|
||||||
tint = iconColor,
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
)
|
||||||
}
|
// Блик сверху для стеклянного эффекта
|
||||||
|
.background(
|
||||||
// Текстовое поле
|
brush = Brush.verticalGradient(
|
||||||
Box(
|
colors = listOf(
|
||||||
modifier = Modifier
|
Color.White.copy(alpha = if (isDarkTheme) 0.06f else 0.4f),
|
||||||
.weight(1f)
|
Color.Transparent,
|
||||||
.padding(vertical = 8.dp),
|
Color.Black.copy(alpha = if (isDarkTheme) 0.03f else 0.02f)
|
||||||
contentAlignment = Alignment.CenterStart
|
),
|
||||||
) {
|
startY = 0f,
|
||||||
BasicTextField(
|
endY = 80f
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
|
||||||
color = textColor,
|
|
||||||
fontSize = 16.sp
|
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(PrimaryBlue),
|
shape = RoundedCornerShape(22.dp)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
maxLines = 5,
|
) {
|
||||||
decorationBox = { innerTextField ->
|
Row(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
contentAlignment = Alignment.CenterStart
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
if (value.isEmpty()) {
|
) {
|
||||||
Text(
|
// Кнопка смайликов (слева внутри инпута)
|
||||||
text = "Message",
|
IconButton(
|
||||||
color = placeholderColor.copy(alpha = 0.6f),
|
onClick = { showEmojiPicker = !showEmojiPicker },
|
||||||
fontSize = 16.sp
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текстовое поле
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(PrimaryBlue),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 5,
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Message",
|
||||||
|
color = placeholderColor.copy(alpha = 0.6f),
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
}
|
}
|
||||||
innerTextField()
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
// Кнопка прикрепления (справа внутри инпута)
|
||||||
// Кнопка прикрепления (справа внутри инпута)
|
IconButton(
|
||||||
IconButton(
|
onClick = { /* TODO: Attachment picker */ },
|
||||||
onClick = { /* TODO: Attachment picker */ },
|
modifier = Modifier.size(36.dp)
|
||||||
modifier = Modifier.size(36.dp)
|
) {
|
||||||
) {
|
Icon(
|
||||||
Icon(
|
Icons.Default.AttachFile,
|
||||||
Icons.Default.AttachFile,
|
contentDescription = "Attach",
|
||||||
contentDescription = "Attach",
|
tint = iconColor,
|
||||||
tint = iconColor,
|
modifier = Modifier.size(22.dp)
|
||||||
modifier = Modifier.size(22.dp)
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
// Кнопка камеры (справа внутри инпута)
|
||||||
// Кнопка камеры (справа внутри инпута)
|
IconButton(
|
||||||
IconButton(
|
onClick = { /* TODO: Camera */ },
|
||||||
onClick = { /* TODO: Camera */ },
|
modifier = Modifier.size(36.dp)
|
||||||
modifier = Modifier.size(36.dp)
|
) {
|
||||||
) {
|
Icon(
|
||||||
Icon(
|
Icons.Default.CameraAlt,
|
||||||
Icons.Default.CameraAlt,
|
contentDescription = "Camera",
|
||||||
contentDescription = "Camera",
|
tint = iconColor,
|
||||||
tint = iconColor,
|
modifier = Modifier.size(22.dp)
|
||||||
modifier = Modifier.size(22.dp)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
|
// Кнопка отправки с плавной анимацией
|
||||||
// Кнопка отправки (появляется только когда есть текст)
|
Box(
|
||||||
AnimatedVisibility(
|
|
||||||
visible = value.isNotBlank(),
|
|
||||||
enter = scaleIn(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)),
|
|
||||||
exit = scaleOut(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150))
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = sendButtonScale
|
||||||
|
scaleY = sendButtonScale
|
||||||
|
rotationZ = sendButtonRotation
|
||||||
|
alpha = sendButtonAlpha
|
||||||
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
PrimaryBlue,
|
PrimaryBlue,
|
||||||
PrimaryBlue.copy(alpha = 0.85f)
|
Color(0xFF5B8DEF)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.clickable(enabled = sendButtonVisible) { onSend() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Send,
|
Icons.Default.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
// Дополнительная микроанимация иконки
|
||||||
|
translationX = 2.dp.toPx()
|
||||||
|
translationY = -1.dp.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель выбора эмодзи с 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user