feat: Implement Telegram-style message animations and frosted glass effects in ChatDetailScreen
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user