From 02e95ccd2b719454daeae89cf9498bd364e66bcd Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 17 Mar 2026 17:45:47 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20sh?= =?UTF-8?q?immer=20skeleton=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B8=20=D1=81=D0=B2=D0=B5=D1=82=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatsListSkeleton: - Sweep shimmer (градиент слева направо) вместо простого lerp - Время перенесено в правый верхний угол (как в реальных чатах) - Разные ширины имени/превью для естественного вида - Цвета приведены к iOS/Telegram-стилю (2C2C2E/48484A / E5E5EA/FFFFFF) MessageSkeletonList: - Пузыри чередуются left/right (incoming/outgoing) — раньше все были слева - Высоты пузырей 36–68dp вместо 64–128dp (реалистичные размеры) - Outgoing bubble shape с nearRadius снизу-справа - Группы сообщений с одной стороны имитируют реальный чат Co-Authored-By: Claude Sonnet 4.6 --- .../messenger/ui/chats/ChatsListScreen.kt | 126 +++++++++++------- .../chats/components/ChatDetailComponents.kt | 115 ++++++++-------- 2 files changed, 136 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 2f066f6..fa29f26 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -2886,37 +2886,62 @@ private fun DeviceResolveDialog( @Composable private fun ChatsListSkeleton(isDarkTheme: Boolean) { val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) - val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0) - val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0) + val shimmerBase = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA) + val shimmerHighlight = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFFFFFFF) - val transition = rememberInfiniteTransition(label = "shimmer") - val shimmerProgress by - transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - animation = - tween(durationMillis = 1200, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "shimmerAlpha" + BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + val density = LocalDensity.current + val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f) + val gradientWidthPx = with(density) { 280.dp.toPx() } + + val transition = rememberInfiniteTransition(label = "shimmer") + val shimmerX by transition.animateFloat( + initialValue = -gradientWidthPx, + targetValue = maxWidthPx + gradientWidthPx, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmerX" ) - val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress) - LazyColumn( - modifier = Modifier.fillMaxSize().background(backgroundColor), - userScrollEnabled = false - ) { - items(10) { - SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme) + val shimmerBrush = Brush.linearGradient( + colorStops = arrayOf( + 0f to shimmerBase, + 0.4f to shimmerHighlight, + 0.6f to shimmerHighlight, + 1f to shimmerBase + ), + start = Offset(shimmerX - gradientWidthPx / 2f, 0f), + end = Offset(shimmerX + gradientWidthPx / 2f, 0f) + ) + + val nameWidths = remember { + floatArrayOf(0.38f, 0.52f, 0.44f, 0.61f, 0.35f, 0.48f, 0.56f, 0.41f, 0.63f, 0.39f) + } + val msgWidths = remember { + floatArrayOf(0.72f, 0.55f, 0.82f, 0.64f, 0.78f, 0.51f, 0.69f, 0.87f, 0.60f, 0.74f) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + userScrollEnabled = false + ) { + items(10) { index -> + SkeletonDialogItem( + shimmerBrush = shimmerBrush, + nameWidth = nameWidths[index], + messageWidth = msgWidths[index], + isDarkTheme = isDarkTheme + ) + } } } } @Composable -private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) +private fun SkeletonDialogItem(shimmerBrush: Brush, nameWidth: Float, messageWidth: Float, isDarkTheme: Boolean) { + val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA) Row( modifier = @@ -2932,41 +2957,42 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { modifier = Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) .clip(CircleShape) - .background(shimmerColor) + .background(shimmerBrush) ) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) - Column(modifier = Modifier.weight(1f)) { - // Name placeholder + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) { + // Name + time row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.fillMaxWidth(nameWidth) + .height(15.dp) + .clip(RoundedCornerShape(6.dp)) + .background(shimmerBrush) + ) + Box( + modifier = + Modifier.width(34.dp) + .height(11.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } + // Message preview placeholder Box( modifier = - Modifier.fillMaxWidth(0.45f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) - ) - Spacer(modifier = Modifier.height(8.dp)) - // Message placeholder - Box( - modifier = - Modifier.fillMaxWidth(0.7f) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) + Modifier.fillMaxWidth(messageWidth) + .height(13.dp) + .clip(RoundedCornerShape(5.dp)) + .background(shimmerBrush) ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Time placeholder - Box( - modifier = - Modifier.width(36.dp) - .height(12.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) - ) } Divider( color = dividerColor, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 20ed071..eb12a90 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -2596,19 +2596,40 @@ fun MessageSkeletonList( label = "telegramSkeletonProgress" ) - // Telegram-style deterministic pseudo-randomness. - val widthRandom = remember { floatArrayOf(0.18f, 0.74f, 0.32f, 0.61f, 0.27f, 0.84f, 0.49f, 0.12f) } - val heightRandom = remember { floatArrayOf(0.25f, 0.68f, 0.14f, 0.52f, 0.37f, 0.79f, 0.29f, 0.57f) } + // Deterministic pseudo-random widths (fraction of bubbleMaxWidth) + val widthRandom = remember { + floatArrayOf(0.55f, 0.72f, 0.38f, 0.65f, 0.48f, 0.80f, 0.42f, 0.68f, + 0.35f, 0.75f, 0.58f, 0.44f, 0.70f, 0.52f, 0.82f, 0.40f, + 0.63f, 0.77f, 0.46f, 0.60f, 0.34f, 0.71f, 0.50f, 0.66f) + } + // Realistic message bubble heights in dp (single-line to ~3-line) + val heightRandom = remember { + intArrayOf(44, 52, 40, 60, 36, 56, 44, 48, 64, 40, 52, 44, + 36, 68, 52, 44, 60, 36, 48, 56, 40, 64, 44, 52) + } + // false = incoming (left), true = outgoing (right) + val sidePattern = remember { + booleanArrayOf(false, false, true, true, true, false, true, + false, false, true, true, false, true, true, false, + false, false, true, true, true, false, false, true, true) + } - val bubbleShape = - remember { - RoundedCornerShape( - topStart = TelegramBubbleSpec.bubbleRadius, - topEnd = TelegramBubbleSpec.bubbleRadius, - bottomStart = TelegramBubbleSpec.nearRadius, - bottomEnd = TelegramBubbleSpec.bubbleRadius - ) - } + val incomingShape = remember { + RoundedCornerShape( + topStart = TelegramBubbleSpec.bubbleRadius, + topEnd = TelegramBubbleSpec.bubbleRadius, + bottomStart = TelegramBubbleSpec.nearRadius, + bottomEnd = TelegramBubbleSpec.bubbleRadius + ) + } + val outgoingShape = remember { + RoundedCornerShape( + topStart = TelegramBubbleSpec.bubbleRadius, + topEnd = TelegramBubbleSpec.bubbleRadius, + bottomStart = TelegramBubbleSpec.bubbleRadius, + bottomEnd = TelegramBubbleSpec.nearRadius + ) + } BoxWithConstraints(modifier = modifier.fillMaxSize()) { val density = LocalDensity.current @@ -2616,37 +2637,33 @@ fun MessageSkeletonList( val gradientWidthPx = with(density) { 200.dp.toPx() } val shimmerX = shimmerProgress * maxWidthPx - val color0 = - if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000) - val color1 = - if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000) - val outlineColor = - if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF) + val color0 = if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000) + val color1 = if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000) + val outlineColor = if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF) val shimmerBrush = Brush.linearGradient( - colorStops = - arrayOf( - 0f to color1, - 0.4f to color0, - 0.6f to color0, - 1f to color1 - ), + colorStops = arrayOf( + 0f to color1, + 0.4f to color0, + 0.6f to color0, + 1f to color1 + ), start = Offset(shimmerX - gradientWidthPx, 0f), end = Offset(shimmerX, 0f) ) val bottomInset = 58.dp - val containerWidth = this@BoxWithConstraints.maxWidth - val bubbleMaxWidth = (containerWidth * 0.8f) - if (isGroupChat) 42.dp else 0.dp + val avatarSize = 36.dp val avatarGap = 6.dp + val containerWidth = this@BoxWithConstraints.maxWidth + val bubbleMaxWidth = containerWidth * 0.78f - if (isGroupChat) (avatarSize + avatarGap) else 0.dp val availableHeight = (maxHeight - bottomInset).coerceAtLeast(0.dp) var usedHeight = 0.dp var rowCount = 0 - while (rowCount < 24 && usedHeight < availableHeight) { - val randomHeight = heightRandom[rowCount % heightRandom.size] - usedHeight += (64.dp + (randomHeight * 64f).dp + 3.dp) + while (rowCount < heightRandom.size && usedHeight < availableHeight) { + usedHeight += (heightRandom[rowCount].dp + 4.dp) rowCount++ } if (rowCount < 6) rowCount = 6 @@ -2655,49 +2672,37 @@ fun MessageSkeletonList( modifier = Modifier.align(Alignment.BottomStart) .fillMaxWidth() - .padding(start = 3.dp, end = 8.dp, bottom = bottomInset), - verticalArrangement = Arrangement.spacedBy(3.dp) + .padding(horizontal = 8.dp, bottom = bottomInset), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { repeat(rowCount) { index -> - val lineWidth = - minOf( - bubbleMaxWidth, - 42.dp + - containerWidth * - (0.4f + - widthRandom[index % widthRandom.size] * - 0.35f) - ) - val lineHeight = - 64.dp + - (heightRandom[index % heightRandom.size] * 64f).dp + val isOutgoing = sidePattern[index % sidePattern.size] + val bubbleWidth = bubbleMaxWidth * widthRandom[index % widthRandom.size] + val bubbleHeight = heightRandom[index % heightRandom.size].dp + val shape = if (isOutgoing) outgoingShape else incomingShape Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom ) { - if (isGroupChat) { + if (!isOutgoing && isGroupChat) { Box( modifier = - Modifier.size(42.dp) + Modifier.size(avatarSize) .clip(CircleShape) .background(shimmerBrush) .border(1.dp, outlineColor, CircleShape) ) Spacer(modifier = Modifier.width(avatarGap)) } - Box( modifier = - Modifier.width(lineWidth) - .height(lineHeight) - .clip(bubbleShape) + Modifier.width(bubbleWidth) + .height(bubbleHeight) + .clip(shape) .background(shimmerBrush) - .border( - width = 1.dp, - color = outlineColor, - shape = bubbleShape - ) + .border(1.dp, outlineColor, shape) ) } }