From 87067a42e340a9b9fc8b4122d8d2645232598ed7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 4 Feb 2026 04:39:38 +0500 Subject: [PATCH] fix: improve avatar loading and rendering logic to prevent flickering and enhance performance --- .../messenger/ui/components/AvatarImage.kt | 34 ++- .../metaball/ProfileMetaballOverlay.kt | 268 +++++++++--------- .../messenger/ui/settings/ProfileScreen.kt | 43 ++- 3 files changed, 190 insertions(+), 155 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 09d0e2d..38816d3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -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(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(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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index fc3356d..08d45bb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -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 + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index bc3e2f2..fd4b886 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -1047,26 +1047,42 @@ private fun FullSizeAvatar( avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: remember { mutableStateOf(emptyList()) } - // Сохраняем bitmap в remember чтобы не мигал при recomposition - var bitmap by remember { mutableStateOf(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(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 - ничего не показываем (прозрачно) + // Это только самый первый момент загрузки } }