feat: Improve send button animation in MessageInputBar for smoother UX

This commit is contained in:
k1ngsterr1
2026-01-10 21:08:27 +05:00
parent 9c0fae385c
commit 58ec38ecb4
1910 changed files with 467 additions and 256 deletions

View File

@@ -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
)
}
}
}
}
}