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 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user