feat: Implement Telegram-style message animations and frosted glass effects in ChatDetailScreen

This commit is contained in:
k1ngsterr1
2026-01-11 05:30:06 +05:00
parent 286d9b21c7
commit a493bb7378

View File

@@ -57,6 +57,18 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.draw.shadow
import androidx.compose.animation.core.CubicBezierEasing
// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910)
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
/**
* Модель сообщения (Legacy - для совместимости)
@@ -122,7 +134,7 @@ fun ChatDetailScreen(
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val backgroundColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFEFEFF3)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
@@ -131,7 +143,7 @@ fun ChatDetailScreen(
var isVisible by remember { mutableStateOf(false) }
val screenAlpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing),
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "screenFade"
)
@@ -242,16 +254,16 @@ fun ChatDetailScreen(
.fillMaxSize()
.graphicsLayer { alpha = screenAlpha }
) {
// Цвета для матового стекла
// Цвета для матового стекла (более прозрачные для лучшего blur эффекта)
val glassHeaderBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.85f)
Color(0xFF1A1A1A).copy(alpha = 0.7f)
else
Color(0xFFF5F5F5).copy(alpha = 0.85f)
Color(0xFFF5F5F5).copy(alpha = 0.7f)
val glassInputPanelBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.88f)
Color(0xFF1A1A1A).copy(alpha = 0.75f)
else
Color(0xFFF5F5F5).copy(alpha = 0.88f)
Color(0xFFF5F5F5).copy(alpha = 0.75f)
Scaffold(
topBar = {
@@ -260,6 +272,7 @@ fun ChatDetailScreen(
modifier = Modifier
.fillMaxWidth()
.background(glassHeaderBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) {
Row(
modifier = Modifier
@@ -554,24 +567,223 @@ fun ChatDetailScreen(
}
/**
* 🚀 Пузырек сообщения с fade-in анимацией (только при первом появлении)
* 🚀 Анимация появления сообщения Telegram-style
*/
@Composable
fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
var animationPlayed by remember(messageId) { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (animationPlayed) 1f else 0f,
animationSpec = tween(
durationMillis = 250,
easing = TelegramEasing
),
label = "messageAlpha"
)
val translationY by animateFloatAsState(
targetValue = if (animationPlayed) 0f else 20f,
animationSpec = tween(
durationMillis = 250,
easing = TelegramEasing
),
label = "messageTranslationY"
)
LaunchedEffect(messageId) {
delay(16) // One frame delay
animationPlayed = true
}
return Pair(alpha, translationY)
}
/**
* 🚀 Telegram-style bubble shape с хвостиком
*/
class TelegramBubbleShape(
private val isOutgoing: Boolean,
private val radius: Dp = 18.dp,
private val tailSize: Dp = 6.dp
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path()
val radiusPx = with(density) { radius.toPx() }
val tailSizePx = with(density) { tailSize.toPx() }
val padding = with(density) { 2.dp.toPx() }
if (isOutgoing) {
// Исходящее сообщение - хвостик справа внизу
// Начинаем с правого нижнего угла (перед хвостиком)
path.moveTo(size.width - 2.6f * density.density, size.height - padding)
// Линия к левому нижнему углу
path.lineTo(padding + radiusPx, size.height - padding)
// Левый нижний угол
path.arcTo(
rect = Rect(
left = padding,
top = size.height - padding - radiusPx * 2,
right = padding + radiusPx * 2,
bottom = size.height - padding
),
startAngleDegrees = 90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
// Левая сторона вверх
path.lineTo(padding, padding + radiusPx)
// Левый верхний угол
path.arcTo(
rect = Rect(
left = padding,
top = padding,
right = padding + radiusPx * 2,
bottom = padding + radiusPx * 2
),
startAngleDegrees = 180f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
// Верхняя сторона вправо
path.lineTo(size.width - 8.dp.toPx() * density.density - radiusPx, padding)
// Правый верхний угол (с небольшим отступом для хвостика)
path.arcTo(
rect = Rect(
left = size.width - 8.dp.toPx() * density.density - radiusPx * 2,
top = padding,
right = size.width - 8.dp.toPx() * density.density,
bottom = padding + radiusPx * 2
),
startAngleDegrees = 270f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
// Правая сторона вниз (до хвостика)
path.lineTo(
size.width - 8.dp.toPx() * density.density,
size.height - padding - tailSizePx - 3.dp.toPx() * density.density
)
// Хвостик (маленькая дуга)
path.arcTo(
rect = Rect(
left = size.width - 8.dp.toPx() * density.density,
top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density,
right = size.width - 7.dp.toPx() * density.density + tailSizePx * 2,
bottom = size.height - padding - 1.dp.toPx() * density.density
),
startAngleDegrees = 180f,
sweepAngleDegrees = -83f,
forceMoveTo = false
)
} else {
// Входящее сообщение - хвостик слева внизу
path.moveTo(2.6f * density.density, size.height - padding)
// Линия к правому нижнему углу
path.lineTo(size.width - padding - radiusPx, size.height - padding)
// Правый нижний угол
path.arcTo(
rect = Rect(
left = size.width - padding - radiusPx * 2,
top = size.height - padding - radiusPx * 2,
right = size.width - padding,
bottom = size.height - padding
),
startAngleDegrees = 90f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
// Правая сторона вверх
path.lineTo(size.width - padding, padding + radiusPx)
// Правый верхний угол
path.arcTo(
rect = Rect(
left = size.width - padding - radiusPx * 2,
top = padding,
right = size.width - padding,
bottom = padding + radiusPx * 2
),
startAngleDegrees = 0f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
// Верхняя сторона влево
path.lineTo(8.dp.toPx() * density.density + radiusPx, padding)
// Левый верхний угол
path.arcTo(
rect = Rect(
left = 8.dp.toPx() * density.density,
top = padding,
right = 8.dp.toPx() * density.density + radiusPx * 2,
bottom = padding + radiusPx * 2
),
startAngleDegrees = 270f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
// Левая сторона вниз (до хвостика)
path.lineTo(
8.dp.toPx() * density.density,
size.height - padding - tailSizePx - 3.dp.toPx() * density.density
)
// Хвостик (маленькая дуга)
path.arcTo(
rect = Rect(
left = 7.dp.toPx() * density.density - tailSizePx * 2,
top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density,
right = 8.dp.toPx() * density.density,
bottom = size.height - padding - 1.dp.toPx() * density.density
),
startAngleDegrees = 0f,
sweepAngleDegrees = 83f,
forceMoveTo = false
)
}
path.close()
return Outline.Generic(path)
}
}
/**
* 🚀 Пузырек сообщения Telegram-style с хвостиком
*/
@Composable
private fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
index: Int = 0 // Для staggered анимации
index: Int = 0
) {
// 🔥 Fade-in + slide анимация - используем key для предотвращения повторной анимации
var isVisible by remember(message.id) { mutableStateOf(true) } // Сразу true - без повторной анимации
// Telegram-style enter animation
val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
val bubbleColor = if (message.isOutgoing) {
PrimaryBlue
} else {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
}
val textColor = if (message.isOutgoing) Color.White else {
if (isDarkTheme) Color.White else Color.Black
if (isDarkTheme) Color.White else Color(0xFF000000)
}
val timeColor = if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else {
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
@@ -582,22 +794,39 @@ private fun MessageBubble(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
.padding(vertical = 2.dp)
.graphicsLayer {
this.alpha = alpha
this.translationY = translationY
},
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.shadow(
elevation = if (message.isOutgoing) 0.dp else 0.5.dp,
shape = TelegramBubbleShape(
isOutgoing = message.isOutgoing,
radius = 18.dp,
tailSize = 6.dp
),
clip = false
)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isOutgoing) 16.dp else 4.dp,
bottomEnd = if (message.isOutgoing) 4.dp else 16.dp
TelegramBubbleShape(
isOutgoing = message.isOutgoing,
radius = 18.dp,
tailSize = 6.dp
)
)
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
.padding(
start = if (message.isOutgoing) 12.dp else 16.dp,
end = if (message.isOutgoing) 16.dp else 12.dp,
top = 8.dp,
bottom = 8.dp
)
) {
Column {
AppleEmojiText(
@@ -650,7 +879,7 @@ private fun DateHeader(
var isVisible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "dateAlpha"
)
@@ -771,6 +1000,7 @@ private fun MessageInputBar(
modifier = Modifier
.fillMaxWidth()
.background(panelBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) {
// Верхняя линия для разделения (эффект стекла)
Box(