Улучшен shimmer skeleton для темной и светлой темы

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 17:45:47 +07:00
parent 5e04301539
commit 02e95ccd2b
2 changed files with 136 additions and 105 deletions

View File

@@ -2886,37 +2886,62 @@ private fun DeviceResolveDialog(
@Composable @Composable
private fun ChatsListSkeleton(isDarkTheme: Boolean) { private fun ChatsListSkeleton(isDarkTheme: Boolean) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0) val shimmerBase = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA)
val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0) val shimmerHighlight = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFFFFFFF)
val transition = rememberInfiniteTransition(label = "shimmer") BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val shimmerProgress by val density = LocalDensity.current
transition.animateFloat( val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
initialValue = 0f, val gradientWidthPx = with(density) { 280.dp.toPx() }
targetValue = 1f,
animationSpec = val transition = rememberInfiniteTransition(label = "shimmer")
infiniteRepeatable( val shimmerX by transition.animateFloat(
animation = initialValue = -gradientWidthPx,
tween(durationMillis = 1200, easing = LinearEasing), targetValue = maxWidthPx + gradientWidthPx,
repeatMode = RepeatMode.Restart animationSpec = infiniteRepeatable(
), animation = tween(durationMillis = 1400, easing = LinearEasing),
label = "shimmerAlpha" repeatMode = RepeatMode.Restart
),
label = "shimmerX"
) )
val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress)
LazyColumn( val shimmerBrush = Brush.linearGradient(
modifier = Modifier.fillMaxSize().background(backgroundColor), colorStops = arrayOf(
userScrollEnabled = false 0f to shimmerBase,
) { 0.4f to shimmerHighlight,
items(10) { 0.6f to shimmerHighlight,
SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme) 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 @Composable
private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { private fun SkeletonDialogItem(shimmerBrush: Brush, nameWidth: Float, messageWidth: Float, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
Row( Row(
modifier = modifier =
@@ -2932,41 +2957,42 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
modifier = modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape) .clip(CircleShape)
.background(shimmerColor) .background(shimmerBrush)
) )
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) {
// Name placeholder // 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( Box(
modifier = modifier =
Modifier.fillMaxWidth(0.45f) Modifier.fillMaxWidth(messageWidth)
.height(16.dp) .height(13.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(5.dp))
.background(shimmerColor) .background(shimmerBrush)
)
Spacer(modifier = Modifier.height(8.dp))
// Message placeholder
Box(
modifier =
Modifier.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
) )
} }
Spacer(modifier = Modifier.width(8.dp))
// Time placeholder
Box(
modifier =
Modifier.width(36.dp)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
)
} }
Divider( Divider(
color = dividerColor, color = dividerColor,

View File

@@ -2596,19 +2596,40 @@ fun MessageSkeletonList(
label = "telegramSkeletonProgress" label = "telegramSkeletonProgress"
) )
// Telegram-style deterministic pseudo-randomness. // Deterministic pseudo-random widths (fraction of bubbleMaxWidth)
val widthRandom = remember { floatArrayOf(0.18f, 0.74f, 0.32f, 0.61f, 0.27f, 0.84f, 0.49f, 0.12f) } val widthRandom = remember {
val heightRandom = remember { floatArrayOf(0.25f, 0.68f, 0.14f, 0.52f, 0.37f, 0.79f, 0.29f, 0.57f) } 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 = val incomingShape = remember {
remember { RoundedCornerShape(
RoundedCornerShape( topStart = TelegramBubbleSpec.bubbleRadius,
topStart = TelegramBubbleSpec.bubbleRadius, topEnd = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius, bottomStart = TelegramBubbleSpec.nearRadius,
bottomStart = TelegramBubbleSpec.nearRadius, bottomEnd = TelegramBubbleSpec.bubbleRadius
bottomEnd = TelegramBubbleSpec.bubbleRadius )
) }
} val outgoingShape = remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.bubbleRadius,
bottomEnd = TelegramBubbleSpec.nearRadius
)
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -2616,37 +2637,33 @@ fun MessageSkeletonList(
val gradientWidthPx = with(density) { 200.dp.toPx() } val gradientWidthPx = with(density) { 200.dp.toPx() }
val shimmerX = shimmerProgress * maxWidthPx val shimmerX = shimmerProgress * maxWidthPx
val color0 = val color0 = if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000)
if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000) val color1 = if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val color1 = val outlineColor = if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val outlineColor =
if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
val shimmerBrush = val shimmerBrush =
Brush.linearGradient( Brush.linearGradient(
colorStops = colorStops = arrayOf(
arrayOf( 0f to color1,
0f to color1, 0.4f to color0,
0.4f to color0, 0.6f to color0,
0.6f to color0, 1f to color1
1f to color1 ),
),
start = Offset(shimmerX - gradientWidthPx, 0f), start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f) end = Offset(shimmerX, 0f)
) )
val bottomInset = 58.dp val bottomInset = 58.dp
val containerWidth = this@BoxWithConstraints.maxWidth val avatarSize = 36.dp
val bubbleMaxWidth = (containerWidth * 0.8f) - if (isGroupChat) 42.dp else 0.dp
val avatarGap = 6.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) val availableHeight = (maxHeight - bottomInset).coerceAtLeast(0.dp)
var usedHeight = 0.dp var usedHeight = 0.dp
var rowCount = 0 var rowCount = 0
while (rowCount < 24 && usedHeight < availableHeight) { while (rowCount < heightRandom.size && usedHeight < availableHeight) {
val randomHeight = heightRandom[rowCount % heightRandom.size] usedHeight += (heightRandom[rowCount].dp + 4.dp)
usedHeight += (64.dp + (randomHeight * 64f).dp + 3.dp)
rowCount++ rowCount++
} }
if (rowCount < 6) rowCount = 6 if (rowCount < 6) rowCount = 6
@@ -2655,49 +2672,37 @@ fun MessageSkeletonList(
modifier = modifier =
Modifier.align(Alignment.BottomStart) Modifier.align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 3.dp, end = 8.dp, bottom = bottomInset), .padding(horizontal = 8.dp, bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(3.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
repeat(rowCount) { index -> repeat(rowCount) { index ->
val lineWidth = val isOutgoing = sidePattern[index % sidePattern.size]
minOf( val bubbleWidth = bubbleMaxWidth * widthRandom[index % widthRandom.size]
bubbleMaxWidth, val bubbleHeight = heightRandom[index % heightRandom.size].dp
42.dp + val shape = if (isOutgoing) outgoingShape else incomingShape
containerWidth *
(0.4f +
widthRandom[index % widthRandom.size] *
0.35f)
)
val lineHeight =
64.dp +
(heightRandom[index % heightRandom.size] * 64f).dp
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
if (isGroupChat) { if (!isOutgoing && isGroupChat) {
Box( Box(
modifier = modifier =
Modifier.size(42.dp) Modifier.size(avatarSize)
.clip(CircleShape) .clip(CircleShape)
.background(shimmerBrush) .background(shimmerBrush)
.border(1.dp, outlineColor, CircleShape) .border(1.dp, outlineColor, CircleShape)
) )
Spacer(modifier = Modifier.width(avatarGap)) Spacer(modifier = Modifier.width(avatarGap))
} }
Box( Box(
modifier = modifier =
Modifier.width(lineWidth) Modifier.width(bubbleWidth)
.height(lineHeight) .height(bubbleHeight)
.clip(bubbleShape) .clip(shape)
.background(shimmerBrush) .background(shimmerBrush)
.border( .border(1.dp, outlineColor, shape)
width = 1.dp,
color = outlineColor,
shape = bubbleShape
)
) )
} }
} }