From f7fdf7f8fe9f8164396ffd0de973c3798568ff4e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 4 Feb 2026 05:51:56 +0500 Subject: [PATCH] fix: enhance avatar expansion and snapping behavior for smoother interactions --- .../metaball/ProfileMetaballOverlay.kt | 141 +++++++++--------- .../ui/settings/OtherProfileScreen.kt | 64 ++++---- .../messenger/ui/settings/ProfileScreen.kt | 57 ++++--- 3 files changed, 126 insertions(+), 136 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 08d45bb..2c6211a 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 @@ -546,51 +546,51 @@ fun ProfileMetaballOverlay( val avatarCenterY = avatarState.centerY // ═══════════════════════════════════════════════════════════ - // UNIFIED SCALE для всех режимов: - // - Normal (expansion=0, collapse=0): scale = 1.0 - // - Expansion (expansion>0): scale растёт до screenWidth/baseSizePx - // - Collapse (collapse>0): scale = avatarState.radius * 2 / baseSizePx + // 🔥 UNIFIED SCALE - БЕЗ резких переключений when + // Всегда вычисляем scale на основе avatarState.radius + // При expansion - дополнительно интерполируем к screenWidth // ═══════════════════════════════════════════════════════════ - val uniformScale: Float - val currentCenterX: Float - val currentCenterY: Float - val cornerRadiusPx: Float - val applyBlur: Boolean - 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 - } + // Базовый scale из avatarState.radius (работает для collapse И normal) + val baseScale = (avatarState.radius * 2f) / baseSizePx + + // При expansion: дополнительно масштабируем к screenWidth + val targetExpansionScale = screenWidthPx / baseSizePx + val uniformScale = if (expansionProgress > 0f) { + // Плавный переход от baseScale к targetExpansionScale + lerpFloat(baseScale, targetExpansionScale, expansionProgress) + } else { + baseScale + }.coerceAtLeast(0.01f) // Защита от деления на 0 + + // Центр: при expansion двигается к центру header + val targetCenterX = screenWidthPx / 2f + val targetCenterY = headerHeightPx / 2f + val currentCenterX = if (expansionProgress > 0f) { + lerpFloat(avatarCenterX, targetCenterX, expansionProgress) + } else { + avatarCenterX } + val currentCenterY = if (expansionProgress > 0f) { + lerpFloat(avatarCenterY, targetCenterY, expansionProgress) + } else { + avatarCenterY + } + + // Corner radius: для base size (120dp) + // В normal/collapse: из avatarState, но пересчитанный для base size + // При expansion: круг → квадрат + val cornerRadiusPx: Float = if (expansionProgress > 0f) { + // Expansion: плавно круг → квадрат + lerpFloat(baseSizePx / 2f, 0f, expansionProgress) + } else { + // Normal/Collapse: используем avatarState.cornerRadius + // Пересчитываем для base size: cornerRadius_base = cornerRadius_current / baseScale + (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) + } + + // Blur только при collapse близко к notch + val applyBlur = expansionProgress == 0f && avatarState.blurRadius > 0.5f // Offset: Box имеет ФИКСИРОВАННЫЙ размер, scale применяется от центра val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } @@ -698,37 +698,34 @@ fun ProfileMetaballOverlayCompat( val avatarCenterX = avatarState.centerX val avatarCenterY = avatarState.centerY - // UNIFIED SCALE для всех режимов - val uniformScale: Float - val currentCenterX: Float - val currentCenterY: Float - val cornerRadiusPx: Float + // 🔥 UNIFIED SCALE - БЕЗ резких переключений when + val baseScale = (avatarState.radius * 2f) / baseSizePx + val targetExpansionScale = screenWidthPx / baseSizePx + val uniformScale = if (expansionProgress > 0f) { + lerpFloat(baseScale, targetExpansionScale, expansionProgress) + } else { + baseScale + }.coerceAtLeast(0.01f) - 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 - } + // Центр: при expansion двигается к центру header + val targetCenterX = screenWidthPx / 2f + val targetCenterY = headerHeightPx / 2f + val currentCenterX = if (expansionProgress > 0f) { + lerpFloat(avatarCenterX, targetCenterX, expansionProgress) + } else { + avatarCenterX + } + val currentCenterY = if (expansionProgress > 0f) { + lerpFloat(avatarCenterY, targetCenterY, expansionProgress) + } else { + avatarCenterY + } + + // Corner radius + val cornerRadiusPx: Float = if (expansionProgress > 0f) { + lerpFloat(baseSizePx / 2f, 0f, expansionProgress) + } else { + (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) } val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 812b3d1..e802a6f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.BackHandler import androidx.core.view.WindowCompat import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -192,7 +194,7 @@ fun OtherProfileScreen( // ═══════════════════════════════════════════════════════════════ var overscrollOffset by remember { mutableFloatStateOf(0f) } val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение - val snapThreshold = maxOverscroll * 0.33f // Telegram: 33% + val snapThreshold = maxOverscroll * 0.05f // 🔥 5% - мгновенный snap! // Track dragging state var isDragging by remember { mutableStateOf(false) } @@ -216,44 +218,51 @@ fun OtherProfileScreen( // ═══════════════════════════════════════════════════════════════ // SNAP ANIMATION - как Telegram's expandAnimator // При отпускании пальца: snap к 0 или к max в зависимости от порога - // ═══════════════════════════════════════════════════════════════ + // 🔥 FIX: isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ + // ═══════════════════════════════════════════════════════════ val targetOverscroll = when { - isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем - isPulledDown -> maxOverscroll // После snap - держим раскрытым - overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max - else -> 0f // Не дотянули - snap обратно + isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал! + isDragging -> overscrollOffset // Во время drag (до порога) + overscrollOffset > snapThreshold -> maxOverscroll + else -> 0f } - // Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse + // 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) - val snapDuration = if (targetOverscroll == maxOverscroll) { - ((1f - currentProgress) * 150).toInt().coerceIn(50, 150) - } else { - (currentProgress * 150).toInt().coerceIn(50, 150) - } - + // 🔥 Плавная spring анимация для snap val animatedOverscroll by animateFloatAsState( targetValue = targetOverscroll, - animationSpec = tween( - durationMillis = if (isDragging) 0 else snapDuration, - easing = LinearOutSlowInEasing - ), + animationSpec = if (isDragging && !isPulledDown) { + // Без анимации во время drag (до snap) + spring(stiffness = Spring.StiffnessHigh) + } else { + // Плавная анимация для snap + spring( + dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce + stiffness = Spring.StiffnessMediumLow // Плавное движение + ) + }, label = "overscroll" ) // ExpansionProgress для передачи в overlay + // 🔥 FIX: Когда isPulledDown=true - ВСЕГДА используем animatedOverscroll val expansionProgress = when { - collapseProgress > 0.1f -> 0f // Не расширяем при collapse + collapseProgress > 0.1f -> 0f + isPulledDown -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // 🔥 AUTO-EXPAND! isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) } // Haptic при достижении порога (как Telegram) - LaunchedEffect(expansionProgress) { - if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) { + // 🔥 FIX: Также автоматически snap к expanded + LaunchedEffect(expansionProgress, isDragging) { + if (expansionProgress >= 0.10f && !hasTriggeredExpandHaptic && isDragging) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hasTriggeredExpandHaptic = true - } else if (expansionProgress < 0.2f) { + // 🔥 AUTO-SNAP! + isPulledDown = true + } else if (expansionProgress < 0.02f) { hasTriggeredExpandHaptic = false } } @@ -484,19 +493,6 @@ private fun CollapsingOtherProfileHeader( // Avatar font size for placeholder val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress) - // Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ) - val hapticFeedback = LocalHapticFeedback.current - var hasTriggeredHaptic by remember { mutableStateOf(false) } - - LaunchedEffect(expansionProgress, hasAvatar) { - if (hasAvatar && expansionProgress >= 0.95f && !hasTriggeredHaptic) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - hasTriggeredHaptic = true - } else if (expansionProgress < 0.5f) { - hasTriggeredHaptic = false - } - } - // Text animation - always centered val textDefaultY = expandedHeight - 48.dp val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2 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 fd4b886..78e3d54 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 @@ -339,7 +339,7 @@ fun ProfileScreen( // ═══════════════════════════════════════════════════════════════ var overscrollOffset by remember { mutableFloatStateOf(0f) } val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение - val snapThreshold = maxOverscroll * 0.33f // Telegram: 33% + val snapThreshold = maxOverscroll * 0.05f // 🔥 5% - мгновенный snap! // Track dragging state var isDragging by remember { mutableStateOf(false) } @@ -357,45 +357,55 @@ fun ProfileScreen( // ═══════════════════════════════════════════════════════════════ // SNAP ANIMATION - как Telegram's expandAnimator // При отпускании пальца: snap к 0 или к max в зависимости от порога + // 🔥 FIX: Если isPulledDown=true - ВСЕГДА snap к max (даже во время drag!) + // isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ - игнорирует isDragging // ═══════════════════════════════════════════════════════════════ val targetOverscroll = when { - isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем - isPulledDown -> maxOverscroll // После snap - держим раскрытым + isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал - держим раскрытым! + isDragging -> overscrollOffset // Во время drag (до порога) - следуем за пальцем overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max else -> 0f // Не дотянули - snap обратно } - // Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse - // Но минимизируем для мгновенного отклика + // 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ + // чтобы аватарка сразу заполнилась после порога val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) - val snapDuration = if (targetOverscroll == maxOverscroll) { - ((1f - currentProgress) * 150).toInt().coerceIn(50, 150) // Рост - быстро! - } else { - (currentProgress * 150).toInt().coerceIn(50, 150) // Коллапс - тоже быстро! - } + // 🔥 Плавная spring анимация для snap val animatedOverscroll by animateFloatAsState( targetValue = targetOverscroll, - animationSpec = tween( - durationMillis = if (isDragging) 0 else snapDuration, - easing = LinearOutSlowInEasing // Быстрый старт, плавное завершение - ), + animationSpec = if (isDragging && !isPulledDown) { + // Без анимации во время drag (до snap) + spring(stiffness = Spring.StiffnessHigh) + } else { + // Плавная анимация для snap + spring( + dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce + stiffness = Spring.StiffnessMediumLow // Плавное движение + ) + }, label = "overscroll" ) // ExpansionProgress для передачи в overlay + // 🔥 FIX: Когда isPulledDown=true - ВСЕГДА используем animatedOverscroll + // чтобы аватарка автоматически расширилась до конца val expansionProgress = when { collapseProgress > 0.1f -> 0f // Не расширяем при collapse + isPulledDown -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // 🔥 AUTO-EXPAND! isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) } // Haptic при достижении порога (как Telegram) - LaunchedEffect(expansionProgress) { - if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) { + // 🔥 FIX: Также автоматически snap к expanded при достижении порога + LaunchedEffect(expansionProgress, isDragging) { + if (expansionProgress >= 0.10f && !hasTriggeredExpandHaptic && isDragging) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hasTriggeredExpandHaptic = true - } else if (expansionProgress < 0.2f) { + // 🔥 AUTO-SNAP: После порога автоматически snap к expanded + isPulledDown = true + } else if (expansionProgress < 0.02f) { hasTriggeredExpandHaptic = false } } @@ -853,19 +863,6 @@ private fun CollapsingProfileHeader( // ═══════════════════════════════════════════════════════════ val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress) - // Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ) - val hapticFeedback = LocalHapticFeedback.current - var hasTriggeredHaptic by remember { mutableStateOf(false) } - - LaunchedEffect(expansionProgress, hasAvatar) { - if (hasAvatar && expansionProgress >= 0.95f && !hasTriggeredHaptic) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - hasTriggeredHaptic = true - } else if (expansionProgress < 0.5f) { - hasTriggeredHaptic = false - } - } - // ═══════════════════════════════════════════════════════════ // 📝 TEXT - внизу header зоны, внутри блока // ═══════════════════════════════════════════════════════════