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

View File

@@ -377,8 +377,10 @@ fun ProfileMetaballOverlay(
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
// Avatar state - computed with Telegram's exact logic // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar, notchCenterY, notchRadiusPx) { // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
// Только стабильные параметры (размеры экрана, notch info) как ключи remember
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterY, notchRadiusPx) {
derivedStateOf { derivedStateOf {
computeAvatarState( computeAvatarState(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
@@ -530,93 +532,93 @@ fun ProfileMetaballOverlay(
} // END if (showMetaballLayer) } // END if (showMetaballLayer)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// LAYER 2: Avatar content - TELEGRAM STYLE EXPANSION // LAYER 2: Avatar content - UNIFIED BOX with SCALE
// При expansion: плавно растёт через SCALE (не размер!) // 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание
// Это даёт smooth анимацию как в Telegram // Базовый размер ФИКСИРОВАННЫЙ (baseSizeDp), изменение через graphicsLayer.scale
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
if (avatarState.showBlob) { if (avatarState.showBlob) {
// Базовый размер аватарки // Базовый размер аватарки (ФИКСИРОВАННЫЙ для Box)
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val baseSizePx = with(density) { baseSizeDp.toPx() } val baseSizePx = with(density) { baseSizeDp.toPx() }
// Позиция центра // Позиция центра (из avatarState для collapse, интерполированная для expansion)
val avatarCenterX = avatarState.centerX val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY val avatarCenterY = avatarState.centerY
// При expansion > 0: используем SCALE для плавного роста // ═══════════════════════════════════════════════════════════
// При collapse: используем обычный размер // UNIFIED SCALE для всех режимов:
val isExpanding = expansionProgress > 0f // - 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) { when {
// ═══════════════════════════════════════════════════════════ expansionProgress > 0f -> {
// EXPANSION MODE - TELEGRAM STYLE // EXPANSION MODE
// Круг растёт И СРАЗУ превращается в квадрат - ПАРАЛЛЕЛЬНО! val targetSize = screenWidthPx
// Без задержки - всё происходит одновременно uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
// Target size = ширина экрана (квадрат на всю ширину)
// ═══════════════════════════════════════════════════════════ // Центр смещается к центру header
val targetSize = screenWidthPx val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
// UNIFORM scale - одинаковый по X и Y currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
val uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress) currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
// Центр смещается к центру header блока // Corner radius: круг → квадрат
val targetCenterX = screenWidthPx / 2f cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
val targetCenterY = headerHeightPx / 2f applyBlur = false
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress) }
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress) collapseProgress > 0f -> {
// COLLAPSE MODE - используем avatarState.radius через scale
// Offset для позиционирования (scale от центра) uniformScale = (avatarState.radius * 2f) / baseSizePx
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } currentCenterX = avatarCenterX
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } currentCenterY = avatarCenterY
// cornerRadius нужен относительно BASE size, поэтому делим на scale
// Corner radius: круг → квадрат СРАЗУ, без задержки! cornerRadiusPx = avatarState.cornerRadius / uniformScale
// Telegram: lerp(smallRadius, 0, expandProgress) - напрямую! applyBlur = avatarState.blurRadius > 0.5f
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress) }
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } else -> {
// NORMAL MODE
Box( uniformScale = 1f
modifier = Modifier currentCenterX = avatarCenterX
.offset(x = offsetX, y = offsetY) currentCenterY = avatarCenterY
.width(baseSizeDp) cornerRadiusPx = baseSizePx / 2f // Полный круг
.height(baseSizeDp) applyBlur = false
.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
)
} }
// 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 dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.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 { derivedStateOf {
computeAvatarState( computeAvatarState(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
@@ -684,62 +688,68 @@ fun ProfileMetaballOverlayCompat(
.fillMaxSize() .fillMaxSize()
.clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded .clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded
) { ) {
// ═══════════════════════════════════════════════════════════════
// 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание
// Базовый размер ФИКСИРОВАННЫЙ, изменение через graphicsLayer.scale
// ═══════════════════════════════════════════════════════════════
if (avatarState.showBlob) { if (avatarState.showBlob) {
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val baseSizePx = with(density) { baseSizeDp.toPx() } val baseSizePx = with(density) { baseSizeDp.toPx() }
val avatarCenterX = avatarState.centerX val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY val avatarCenterY = avatarState.centerY
val isExpanding = expansionProgress > 0f
if (isExpanding) { // UNIFIED SCALE для всех режимов
// EXPANSION MODE - scale + corner radius СРАЗУ без задержки val uniformScale: Float
// Target size = ширина экрана (квадрат на всю ширину) val currentCenterX: Float
val targetSize = screenWidthPx val currentCenterY: Float
val uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress) val cornerRadiusPx: Float
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f when {
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress) expansionProgress > 0f -> {
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress) // EXPANSION MODE
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } val targetSize = screenWidthPx
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
// Corner radius уменьшается СРАЗУ, без порога! val targetCenterX = screenWidthPx / 2f
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress) val targetCenterY = headerHeightPx / 2f
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
Box( cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
modifier = Modifier }
.offset(x = offsetX, y = offsetY) collapseProgress > 0f -> {
.width(baseSizeDp) // COLLAPSE MODE - используем avatarState.radius через scale
.height(baseSizeDp) uniformScale = (avatarState.radius * 2f) / baseSizePx
.graphicsLayer { currentCenterX = avatarCenterX
this.scaleX = uniformScale currentCenterY = avatarCenterY
this.scaleY = uniformScale cornerRadiusPx = avatarState.cornerRadius / uniformScale
alpha = avatarState.opacity }
} else -> {
.clip(RoundedCornerShape(cornerRadiusDp)), // NORMAL MODE
contentAlignment = Alignment.Center, uniformScale = 1f
content = avatarContent currentCenterX = avatarCenterX
) currentCenterY = avatarCenterY
} else { cornerRadiusPx = baseSizePx / 2f
// 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
)
} }
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() avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
// Сохраняем bitmap в remember чтобы не мигал при recomposition // 🔥 FIX: Используем стабильный ключ (timestamp) вместо reference списка
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) } // Это предотвращает перезагрузку bitmap при recomposition когда данные не изменились
var isLoading by remember { mutableStateOf(true) } val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L
}
LaunchedEffect(avatars) { // 🔥 FIX: Сохраняем bitmap БЕЗ сброса при изменении ключа
if (avatars.isNotEmpty()) { // Предыдущий 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) { val newBitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap( com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(
avatars.first().base64Data currentAvatars.first().base64Data
) )
} }
bitmap = newBitmap // Устанавливаем новый bitmap только если декодирование успешно
isLoading = false if (newBitmap != null) {
bitmap = newBitmap
}
initialLoadComplete = true
} else { } else {
isLoading = false // Нет аватарки - помечаем загрузку завершенной
initialLoadComplete = true
} }
} }
// Показываем картинку если есть, иначе placeholder // Показываем картинку если есть, иначе placeholder
// НО не показываем placeholder пока идёт загрузка (чтобы не мигало) // 🔥 FIX: Показываем bitmap сразу если он есть (даже во время загрузки нового)
when { when {
bitmap != null -> { bitmap != null -> {
Image( Image(
@@ -1076,8 +1092,8 @@ private fun FullSizeAvatar(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
!isLoading -> { initialLoadComplete -> {
// Placeholder только когда точно нет аватарки // Placeholder только когда точно нет аватарки И загрузка завершена
val avatarColors = getAvatarColor(publicKey, isDarkTheme) val avatarColors = getAvatarColor(publicKey, isDarkTheme)
Box( Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor), modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
@@ -1091,7 +1107,8 @@ private fun FullSizeAvatar(
) )
} }
} }
// Пока isLoading=true - ничего не показываем (прозрачно) // Пока initialLoadComplete=false - ничего не показываем (прозрачно)
// Это только самый первый момент загрузки
} }
} }