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