Улучшен 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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user