fix: improve avatar loading and rendering logic to prevent flickering and enhance performance
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 - ничего не показываем (прозрачно)
|
||||
// Это только самый первый момент загрузки
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user