From 6d15a345123feb5b2d72b4ec109d0c65b591838b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 1 Feb 2026 15:10:21 +0500 Subject: [PATCH] feat: implement Telegram-style avatar expansion with smooth scaling and snap animation in ProfileScreen --- .../metaball/ProfileMetaballOverlay.kt | 204 ++++++++++++------ .../messenger/ui/settings/ProfileScreen.kt | 192 +++++++++++------ 2 files changed, 266 insertions(+), 130 deletions(-) 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 8d78ce5..25f4b74 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 @@ -528,48 +528,94 @@ fun ProfileMetaballOverlay( } } // END if (showMetaballLayer) - // LAYER 2: Actual avatar content - with blur and shape transition like Telegram + // ═══════════════════════════════════════════════════════════════ + // LAYER 2: Avatar content - TELEGRAM STYLE EXPANSION + // При expansion: плавно растёт через SCALE (не размер!) + // Это даёт smooth анимацию как в Telegram + // ═══════════════════════════════════════════════════════════════ if (avatarState.showBlob) { - // При раскрытии - занимаем всю ширину экрана и всю высоту header'а - val isExpanded = expansionProgress > 0f - val avatarWidthDp = with(density) { - if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp() - } - val avatarHeightDp = with(density) { - if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp() - } - val avatarOffsetX = with(density) { - if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp() - } - val avatarOffsetY = with(density) { - if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp() - } + // Базовый размер аватарки + val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED + val baseSizePx = with(density) { baseSizeDp.toPx() } - // Corner radius from state - transitions from circle to rounded rect - val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() } + // Позиция центра + val avatarCenterX = avatarState.centerX + val avatarCenterY = avatarState.centerY - Box( - modifier = Modifier - .offset(x = avatarOffsetX, y = avatarOffsetY) - .width(avatarWidthDp) - .height(avatarHeightDp) - // Use dynamic corner radius - circle → rounded rect transition - .clip(RoundedCornerShape(cornerRadiusDp)) - .graphicsLayer { - alpha = avatarState.opacity - // Apply blur to avatar when near notch (like Telegram) - // Telegram: blurRadius = 2 + (1 - fraction) * 20 - if (avatarState.blurRadius > 0.5f) { - renderEffect = RenderEffect.createBlurEffect( - avatarState.blurRadius, - avatarState.blurRadius, - Shader.TileMode.DECAL - ).asComposeRenderEffect() + // При expansion > 0: используем SCALE для плавного роста + // При collapse: используем обычный размер + val isExpanding = expansionProgress > 0f + + if (isExpanding) { + // ═══════════════════════════════════════════════════════════ + // EXPANSION MODE - плавный рост через scale + // ═══════════════════════════════════════════════════════════ + val targetWidth = screenWidthPx + val targetHeight = headerHeightPx + + // Scale от базового размера до целевого + val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress) + val scaleY = lerpFloat(1f, targetHeight / 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 плавно уменьшается к 0 (квадрат) + // Но только после 70% expansion для эффекта как в Telegram + val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f) + val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress) + val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } + + Box( + modifier = Modifier + .offset(x = offsetX, y = offsetY) + .width(baseSizeDp) + .height(baseSizeDp) + .graphicsLayer { + this.scaleX = scaleX + this.scaleY = scaleY + alpha = avatarState.opacity } - }, - contentAlignment = Alignment.Center, - content = avatarContent - ) + .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 + ) + } } } } @@ -635,34 +681,62 @@ fun ProfileMetaballOverlayCompat( Box(modifier = modifier.fillMaxSize()) { if (avatarState.showBlob) { - // При раскрытии - занимаем всю ширину экрана и всю высоту header'а - val isExpanded = expansionProgress > 0f - val avatarWidthDp = with(density) { - if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp() - } - val avatarHeightDp = with(density) { - if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp() - } - val avatarOffsetX = with(density) { - if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp() - } - val avatarOffsetY = with(density) { - if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp() - } - val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() } + val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED + val baseSizePx = with(density) { baseSizeDp.toPx() } + val avatarCenterX = avatarState.centerX + val avatarCenterY = avatarState.centerY + val isExpanding = expansionProgress > 0f - Box( - modifier = Modifier - .offset(x = avatarOffsetX, y = avatarOffsetY) - .width(avatarWidthDp) - .height(avatarHeightDp) - .clip(RoundedCornerShape(cornerRadiusDp)) - .graphicsLayer { - alpha = avatarState.opacity - }, - contentAlignment = Alignment.Center, - content = avatarContent - ) + if (isExpanding) { + // EXPANSION MODE - scale animation + val targetWidth = screenWidthPx + val targetHeight = headerHeightPx + val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress) + val scaleY = lerpFloat(1f, targetHeight / 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() } + val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f) + val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress) + val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } + + Box( + modifier = Modifier + .offset(x = offsetX, y = offsetY) + .width(baseSizeDp) + .height(baseSizeDp) + .graphicsLayer { + this.scaleX = scaleX + this.scaleY = scaleY + 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 + ) + } } } } 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 9e082e2..13ecd2b 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 @@ -305,65 +305,95 @@ fun ProfileScreen( (expandedHeightPx - scrollOffset).coerceAtLeast(collapsedHeightPx).toDp() } - // Track overscroll offset for avatar expansion (скролл вниз при достижении верха) - // Telegram: когда extraHeight > headerExtraHeight - это isPulledDown + // ═══════════════════════════════════════════════════════════════ + // TELEGRAM-STYLE AVATAR EXPANSION + // При свайпе вниз от верха списка - аватарка расширяется + // Порог snap = 33% (как в Telegram: expandProgress >= 0.33f) + // ═══════════════════════════════════════════════════════════════ var overscrollOffset by remember { mutableFloatStateOf(0f) } - val maxOverscroll = with(density) { 200.dp.toPx() } - val snapThreshold = maxOverscroll * 0.33f // Telegram: expandProgress >= 0.33f + val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение + val snapThreshold = maxOverscroll * 0.33f // Telegram: 33% - // Track if user is currently dragging + // Track dragging state var isDragging by remember { mutableStateOf(false) } - // Track if fully expanded (snapped to full) - var isFullyExpanded by remember { mutableStateOf(false) } + // isPulledDown = зафиксировано в раскрытом состоянии (как Telegram) + var isPulledDown by remember { mutableStateOf(false) } + + // Velocity для учёта скорости свайпа + var lastVelocity by remember { mutableFloatStateOf(0f) } + + // Haptic feedback + val hapticFeedback = LocalHapticFeedback.current + var hasTriggeredExpandHaptic by remember { mutableStateOf(false) } - // Smooth snap animation - только когда отпустили палец - // Telegram использует CubicBezierInterpolator.EASE_BOTH для snap + // ═══════════════════════════════════════════════════════════════ + // SNAP ANIMATION - как Telegram's expandAnimator + // При отпускании пальца: snap к 0 или к max в зависимости от порога + // ═══════════════════════════════════════════════════════════════ val targetOverscroll = when { - isDragging -> overscrollOffset // Во время drag - напрямую - isFullyExpanded -> maxOverscroll // Зафиксировано в раскрытом - overscrollOffset > snapThreshold -> maxOverscroll // Snap к раскрытому - else -> 0f // Snap к закрытому + isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем + isPulledDown -> maxOverscroll // После snap - держим раскрытым + overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max + else -> 0f // Не дотянули - snap обратно + } + + // Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse + val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) + val snapDuration = if (targetOverscroll == maxOverscroll) { + ((1f - currentProgress) * 250).toInt().coerceIn(100, 300) + } else { + (currentProgress * 250).toInt().coerceIn(100, 300) } val animatedOverscroll by animateFloatAsState( targetValue = targetOverscroll, animationSpec = tween( - durationMillis = if (isDragging) 0 else 250, // Мгновенно при drag, плавно при snap - easing = FastOutSlowInEasing // Как Telegram's EASE_BOTH + durationMillis = if (isDragging) 0 else snapDuration, + easing = FastOutSlowInEasing // Telegram: CubicBezierInterpolator.EASE_BOTH ), label = "overscroll" ) - // Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт - // Telegram: expandProgress = (extraHeight - headerExtraHeight) / (listWidth - actionBarHeight - headerOnlyExtraHeight) + // ExpansionProgress для передачи в overlay val expansionProgress = when { - collapseProgress > 0.1f -> 0f // Не расширяем если header коллапсирован - isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) // Напрямую при drag - else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // Анимированно при snap + collapseProgress > 0.1f -> 0f // Не расширяем при collapse + isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) + else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) + } + + // Haptic при достижении порога (как Telegram) + LaunchedEffect(expansionProgress) { + if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + hasTriggeredExpandHaptic = true + } else if (expansionProgress < 0.2f) { + hasTriggeredExpandHaptic = false + } } // DEBUG LOGS - Log.d("ProfileScroll", "scrollOffset=$scrollOffset, collapseProgress=$collapseProgress") - Log.d("ProfileScroll", "overscrollOffset=$overscrollOffset, expansionProgress=$expansionProgress, isDragging=$isDragging") + Log.d("ProfileScroll", "expansionProgress=$expansionProgress, isPulledDown=$isPulledDown, isDragging=$isDragging") - // Nested scroll connection для collapsing + overscroll + // ═══════════════════════════════════════════════════════════════ + // NESTED SCROLL - Telegram style + // ═══════════════════════════════════════════════════════════════ val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.y isDragging = true - // Если тянем вверх (delta < 0) + // Тянем вверх (delta < 0) if (delta < 0) { - // Сначала убираем overscroll если есть + // Сначала убираем overscroll if (overscrollOffset > 0) { val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) val consumed = overscrollOffset - newOffset overscrollOffset = newOffset - // Если вышли из fully expanded - сбросить флаг - if (overscrollOffset < maxOverscroll * 0.9f) { - isFullyExpanded = false + // Сбрасываем isPulledDown если вышли из expanded + if (overscrollOffset < maxOverscroll * 0.5f) { + isPulledDown = false } return Offset(0f, -consumed) } @@ -376,7 +406,7 @@ fun ProfileScreen( } } - // Если тянем вниз (delta > 0) и header коллапсирован - сначала раскрываем + // Тянем вниз (delta > 0) - раскрываем header if (delta > 0 && scrollOffset > 0) { val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f) val consumed = scrollOffset - newScrollOffset @@ -392,10 +422,10 @@ fun ProfileScreen( available: Offset, source: NestedScrollSource ): Offset { - // Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll + // Overscroll при свайпе вниз от верха if (available.y > 0 && scrollOffset == 0f) { - // Telegram: if (!isPulledDown) dy /= 2 - val resistance = if (isFullyExpanded) 1f else 0.5f + // Telegram: сопротивление если ещё не isPulledDown + val resistance = if (isPulledDown) 1f else 0.5f val delta = available.y * resistance overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll) return Offset(0f, available.y) @@ -403,12 +433,34 @@ fun ProfileScreen( return Offset.Zero } + override suspend fun onPreFling(available: Velocity): Velocity { + lastVelocity = available.y + return Velocity.Zero + } + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { isDragging = false - // Если перешли порог - зафиксировать как fully expanded - if (overscrollOffset > snapThreshold) { - isFullyExpanded = true + + // Telegram: snap логика с учётом velocity + // Если velocity > 1000 и тянем вниз - snap to expanded даже если < 33% + // Если velocity < -1000 и тянем вверх - snap to collapsed даже если > 33% + val velocityThreshold = 1000f + + when { + overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> { + // Snap to expanded + isPulledDown = true + } + lastVelocity < -velocityThreshold && overscrollOffset > 0 -> { + // Fast swipe up - snap to collapsed + isPulledDown = false + } + else -> { + // Normal case - snap based on threshold + isPulledDown = overscrollOffset > snapThreshold + } } + return Velocity.Zero } } @@ -997,7 +1049,7 @@ private fun CollapsingProfileHeader( } // ═════════════════════════════════════════════════════════════ -// � FULL SIZE AVATAR - Fills entire container (for expanded state) +// 🖼 FULL SIZE AVATAR - Fills entire container (for expanded state) // ═════════════════════════════════════════════════════════════ @Composable private fun FullSizeAvatar( @@ -1009,41 +1061,51 @@ private fun FullSizeAvatar( avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: remember { mutableStateOf(emptyList()) } - var bitmap by remember(avatars) { mutableStateOf(null) } + // Сохраняем bitmap в remember чтобы не мигал при recomposition + var bitmap by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } LaunchedEffect(avatars) { - bitmap = - if (avatars.isNotEmpty()) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap( - avatars.first().base64Data - ) - } - } else { - null - } + if (avatars.isNotEmpty()) { + val newBitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap( + avatars.first().base64Data + ) + } + bitmap = newBitmap + isLoading = false + } else { + isLoading = false + } } - if (bitmap != null) { - Image( - bitmap = bitmap!!.asImageBitmap(), - contentDescription = "Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - val avatarColors = getAvatarColor(publicKey, isDarkTheme) - Box( - modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center - ) { - Text( - text = publicKey.take(2).uppercase(), - color = avatarColors.textColor, - fontSize = 80.sp, - fontWeight = FontWeight.Bold + // Показываем картинку если есть, иначе placeholder + // НО не показываем placeholder пока идёт загрузка (чтобы не мигало) + when { + bitmap != null -> { + Image( + bitmap = bitmap!!.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } + !isLoading -> { + // Placeholder только когда точно нет аватарки + val avatarColors = getAvatarColor(publicKey, isDarkTheme) + Box( + modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = publicKey.take(2).uppercase(), + color = avatarColors.textColor, + fontSize = 80.sp, + fontWeight = FontWeight.Bold + ) + } + } + // Пока isLoading=true - ничего не показываем (прозрачно) } }