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 kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* 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 - для совместимости) * Модель сообщения (Legacy - для совместимости)
@@ -122,7 +134,7 @@ fun ChatDetailScreen(
) { ) {
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.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 textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
@@ -131,7 +143,7 @@ fun ChatDetailScreen(
var isVisible by remember { mutableStateOf(false) } var isVisible by remember { mutableStateOf(false) }
val screenAlpha by animateFloatAsState( val screenAlpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f, targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "screenFade" label = "screenFade"
) )
@@ -242,16 +254,16 @@ fun ChatDetailScreen(
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = screenAlpha } .graphicsLayer { alpha = screenAlpha }
) { ) {
// Цвета для матового стекла // Цвета для матового стекла (более прозрачные для лучшего blur эффекта)
val glassHeaderBackground = if (isDarkTheme) val glassHeaderBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.85f) Color(0xFF1A1A1A).copy(alpha = 0.7f)
else else
Color(0xFFF5F5F5).copy(alpha = 0.85f) Color(0xFFF5F5F5).copy(alpha = 0.7f)
val glassInputPanelBackground = if (isDarkTheme) val glassInputPanelBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.88f) Color(0xFF1A1A1A).copy(alpha = 0.75f)
else else
Color(0xFFF5F5F5).copy(alpha = 0.88f) Color(0xFFF5F5F5).copy(alpha = 0.75f)
Scaffold( Scaffold(
topBar = { topBar = {
@@ -260,6 +272,7 @@ fun ChatDetailScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(glassHeaderBackground) .background(glassHeaderBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) { ) {
Row( Row(
modifier = Modifier 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 @Composable
private fun MessageBubble( private fun MessageBubble(
message: ChatMessage, message: ChatMessage,
isDarkTheme: Boolean, isDarkTheme: Boolean,
index: Int = 0 // Для staggered анимации index: Int = 0
) { ) {
// 🔥 Fade-in + slide анимация - используем key для предотвращения повторной анимации // Telegram-style enter animation
var isVisible by remember(message.id) { mutableStateOf(true) } // Сразу true - без повторной анимации val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
val bubbleColor = if (message.isOutgoing) { val bubbleColor = if (message.isOutgoing) {
PrimaryBlue PrimaryBlue
} else { } else {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
} }
val textColor = if (message.isOutgoing) Color.White else { 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 { val timeColor = if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else {
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
@@ -582,22 +794,39 @@ private fun MessageBubble(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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 horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.widthIn(max = 280.dp) .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( .clip(
RoundedCornerShape( TelegramBubbleShape(
topStart = 16.dp, isOutgoing = message.isOutgoing,
topEnd = 16.dp, radius = 18.dp,
bottomStart = if (message.isOutgoing) 16.dp else 4.dp, tailSize = 6.dp
bottomEnd = if (message.isOutgoing) 4.dp else 16.dp
) )
) )
.background(bubbleColor) .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 { Column {
AppleEmojiText( AppleEmojiText(
@@ -650,7 +879,7 @@ private fun DateHeader(
var isVisible by remember { mutableStateOf(false) } var isVisible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState( val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f, targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "dateAlpha" label = "dateAlpha"
) )
@@ -771,6 +1000,7 @@ private fun MessageInputBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(panelBackground) .background(panelBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) { ) {
// Верхняя линия для разделения (эффект стекла) // Верхняя линия для разделения (эффект стекла)
Box( Box(