Улучшен 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
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,

View File

@@ -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)
)
}
}