fix: improve avatar loading and rendering logic to prevent flickering and enhance performance

This commit is contained in:
k1ngsterr1
2026-02-04 04:39:38 +05:00
parent 6d9fe931bb
commit 87067a42e3
3 changed files with 190 additions and 155 deletions

View File

@@ -65,23 +65,31 @@ fun AvatarImage(
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Состояние для bitmap
var bitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
// Логируем для отладки
LaunchedEffect(publicKey, avatars) {
// 🔥 FIX: Используем стабильный ключ (timestamp первого аватара) вместо reference списка
// Это предотвращает сброс bitmap при recomposition когда данные не изменились
val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L
}
// Декодируем первый аватар
LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) {
withContext(Dispatchers.IO) {
val result = AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
result
// 🔥 FIX: Состояние для bitmap - НЕ сбрасываем при изменении ключа
// Показываем предыдущий bitmap пока грузится новый (double buffering)
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
// 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился
// НЕ сбрасываем bitmap в null - показываем старый пока грузится новый
LaunchedEffect(avatarKey) {
val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) {
val newBitmap = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
}
// Устанавливаем новый bitmap только если декодирование успешно
if (newBitmap != null) {
bitmap = newBitmap
}
} else {
null
}
// Если avatars пустой - НЕ сбрасываем bitmap в null
// Placeholder покажется через условие bitmap == null ниже
}
Box(

View File

@@ -377,8 +377,10 @@ fun ProfileMetaballOverlay(
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
// Avatar state - computed with Telegram's exact logic
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar, notchCenterY, notchRadiusPx) {
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
// Только стабильные параметры (размеры экрана, notch info) как ключи remember
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterY, notchRadiusPx) {
derivedStateOf {
computeAvatarState(
collapseProgress = collapseProgress,
@@ -530,93 +532,93 @@ fun ProfileMetaballOverlay(
} // END if (showMetaballLayer)
// ═══════════════════════════════════════════════════════════════
// LAYER 2: Avatar content - TELEGRAM STYLE EXPANSION
// При expansion: плавно растёт через SCALE (не размер!)
// Это даёт smooth анимацию как в Telegram
// LAYER 2: Avatar content - UNIFIED BOX with SCALE
// 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание
// Базовый размер ФИКСИРОВАННЫЙ (baseSizeDp), изменение через graphicsLayer.scale
// ═══════════════════════════════════════════════════════════════
if (avatarState.showBlob) {
// Базовый размер аватарки
// Базовый размер аватарки (ФИКСИРОВАННЫЙ для Box)
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val baseSizePx = with(density) { baseSizeDp.toPx() }
// Позиция центра
// Позиция центра (из avatarState для collapse, интерполированная для expansion)
val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY
// При expansion > 0: используем SCALE для плавного роста
// При collapse: используем обычный размер
val isExpanding = expansionProgress > 0f
// ═══════════════════════════════════════════════════════════
// UNIFIED SCALE для всех режимов:
// - Normal (expansion=0, collapse=0): scale = 1.0
// - Expansion (expansion>0): scale растёт до screenWidth/baseSizePx
// - Collapse (collapse>0): scale = avatarState.radius * 2 / baseSizePx
// ═══════════════════════════════════════════════════════════
val uniformScale: Float
val currentCenterX: Float
val currentCenterY: Float
val cornerRadiusPx: Float
val applyBlur: Boolean
if (isExpanding) {
// ═══════════════════════════════════════════════════════════
// EXPANSION MODE - TELEGRAM STYLE
// Круг растёт И СРАЗУ превращается в квадрат - ПАРАЛЛЕЛЬНО!
// Без задержки - всё происходит одновременно
// Target size = ширина экрана (квадрат на всю ширину)
// ═══════════════════════════════════════════════════════════
val targetSize = screenWidthPx
// UNIFORM scale - одинаковый по X и Y
val uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
// Центр смещается к центру header блока
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
// Offset для позиционирования (scale от центра)
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
// Corner radius: круг → квадрат СРАЗУ, без задержки!
// Telegram: lerp(smallRadius, 0, expandProgress) - напрямую!
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
this.scaleX = uniformScale
this.scaleY = uniformScale
alpha = avatarState.opacity
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
} else {
// ═══════════════════════════════════════════════════════════
// COLLAPSE/NORMAL MODE - обычный размер для капли
// ═══════════════════════════════════════════════════════════
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
if (avatarState.blurRadius > 0.5f) {
renderEffect = RenderEffect.createBlurEffect(
avatarState.blurRadius,
avatarState.blurRadius,
Shader.TileMode.DECAL
).asComposeRenderEffect()
}
},
contentAlignment = Alignment.Center,
content = avatarContent
)
when {
expansionProgress > 0f -> {
// EXPANSION MODE
val targetSize = screenWidthPx
uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
// Центр смещается к центру header
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
// Corner radius: круг → квадрат
cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
applyBlur = false
}
collapseProgress > 0f -> {
// COLLAPSE MODE - используем avatarState.radius через scale
uniformScale = (avatarState.radius * 2f) / baseSizePx
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
// cornerRadius нужен относительно BASE size, поэтому делим на scale
cornerRadiusPx = avatarState.cornerRadius / uniformScale
applyBlur = avatarState.blurRadius > 0.5f
}
else -> {
// NORMAL MODE
uniformScale = 1f
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
cornerRadiusPx = baseSizePx / 2f // Полный круг
applyBlur = false
}
}
// Offset: Box имеет ФИКСИРОВАННЫЙ размер, scale применяется от центра
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
// 🔥 ЕДИНЫЙ BOX - без if/else переключения между composables
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp) // ФИКСИРОВАННЫЙ
.height(baseSizeDp) // ФИКСИРОВАННЫЙ
.graphicsLayer {
scaleX = uniformScale
scaleY = uniformScale
alpha = avatarState.opacity
if (applyBlur) {
renderEffect = RenderEffect.createBlurEffect(
avatarState.blurRadius,
avatarState.blurRadius,
Shader.TileMode.DECAL
).asComposeRenderEffect()
}
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
}
}
}
@@ -657,7 +659,9 @@ fun ProfileMetaballOverlayCompat(
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) {
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx) {
derivedStateOf {
computeAvatarState(
collapseProgress = collapseProgress,
@@ -684,62 +688,68 @@ fun ProfileMetaballOverlayCompat(
.fillMaxSize()
.clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded
) {
// ═══════════════════════════════════════════════════════════════
// 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание
// Базовый размер ФИКСИРОВАННЫЙ, изменение через graphicsLayer.scale
// ═══════════════════════════════════════════════════════════════
if (avatarState.showBlob) {
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val baseSizePx = with(density) { baseSizeDp.toPx() }
val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY
val isExpanding = expansionProgress > 0f
if (isExpanding) {
// EXPANSION MODE - scale + corner radius СРАЗУ без задержки
// Target size = ширина экрана (квадрат на всю ширину)
val targetSize = screenWidthPx
val uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
// Corner radius уменьшается СРАЗУ, без порога!
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
this.scaleX = uniformScale
this.scaleY = uniformScale
alpha = avatarState.opacity
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
} else {
// COLLAPSE/NORMAL MODE
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
},
contentAlignment = Alignment.Center,
content = avatarContent
)
// UNIFIED SCALE для всех режимов
val uniformScale: Float
val currentCenterX: Float
val currentCenterY: Float
val cornerRadiusPx: Float
when {
expansionProgress > 0f -> {
// EXPANSION MODE
val targetSize = screenWidthPx
uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
}
collapseProgress > 0f -> {
// COLLAPSE MODE - используем avatarState.radius через scale
uniformScale = (avatarState.radius * 2f) / baseSizePx
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
cornerRadiusPx = avatarState.cornerRadius / uniformScale
}
else -> {
// NORMAL MODE
uniformScale = 1f
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
cornerRadiusPx = baseSizePx / 2f
}
}
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
// 🔥 ЕДИНЫЙ BOX
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
scaleX = uniformScale
scaleY = uniformScale
alpha = avatarState.opacity
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
}
}
}

View File

@@ -1047,26 +1047,42 @@ private fun FullSizeAvatar(
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Сохраняем bitmap в remember чтобы не мигал при recomposition
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
// 🔥 FIX: Используем стабильный ключ (timestamp) вместо reference списка
// Это предотвращает перезагрузку bitmap при recomposition когда данные не изменились
val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L
}
LaunchedEffect(avatars) {
if (avatars.isNotEmpty()) {
// 🔥 FIX: Сохраняем bitmap БЕЗ сброса при изменении ключа
// Предыдущий bitmap показывается пока загружается новый (double buffering)
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// 🔥 FIX: isLoading только для первой загрузки, не сбрасываем если bitmap уже есть
var initialLoadComplete by remember { mutableStateOf(false) }
// 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился
// НЕ сбрасываем bitmap в null перед загрузкой - показываем старый пока грузится новый
LaunchedEffect(avatarKey) {
val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) {
val newBitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(
avatars.first().base64Data
currentAvatars.first().base64Data
)
}
bitmap = newBitmap
isLoading = false
// Устанавливаем новый bitmap только если декодирование успешно
if (newBitmap != null) {
bitmap = newBitmap
}
initialLoadComplete = true
} else {
isLoading = false
// Нет аватарки - помечаем загрузку завершенной
initialLoadComplete = true
}
}
// Показываем картинку если есть, иначе placeholder
// НО не показываем placeholder пока идёт загрузка (чтобы не мигало)
// 🔥 FIX: Показываем bitmap сразу если он есть (даже во время загрузки нового)
when {
bitmap != null -> {
Image(
@@ -1076,8 +1092,8 @@ private fun FullSizeAvatar(
contentScale = ContentScale.Crop
)
}
!isLoading -> {
// Placeholder только когда точно нет аватарки
initialLoadComplete -> {
// Placeholder только когда точно нет аватарки И загрузка завершена
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
@@ -1091,7 +1107,8 @@ private fun FullSizeAvatar(
)
}
}
// Пока isLoading=true - ничего не показываем (прозрачно)
// Пока initialLoadComplete=false - ничего не показываем (прозрачно)
// Это только самый первый момент загрузки
}
}