fix: enhance avatar expansion and snapping behavior for smoother interactions

This commit is contained in:
k1ngsterr1
2026-02-04 05:51:56 +05:00
parent 87067a42e3
commit f7fdf7f8fe
3 changed files with 126 additions and 136 deletions

View File

@@ -546,52 +546,52 @@ fun ProfileMetaballOverlay(
val avatarCenterY = avatarState.centerY val avatarCenterY = avatarState.centerY
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// UNIFIED SCALE для всех режимов: // 🔥 UNIFIED SCALE - БЕЗ резких переключений when
// - Normal (expansion=0, collapse=0): scale = 1.0 // Всегда вычисляем scale на основе avatarState.radius
// - Expansion (expansion>0): scale растёт до screenWidth/baseSizePx // При expansion - дополнительно интерполируем к screenWidth
// - Collapse (collapse>0): scale = avatarState.radius * 2 / baseSizePx
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val uniformScale: Float
val currentCenterX: Float
val currentCenterY: Float
val cornerRadiusPx: Float
val applyBlur: Boolean
when { // Базовый scale из avatarState.radius (работает для collapse И normal)
expansionProgress > 0f -> { val baseScale = (avatarState.radius * 2f) / baseSizePx
// EXPANSION MODE
val targetSize = screenWidthPx
uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
// Центр смещается к центру header // При 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 targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f val targetCenterY = headerHeightPx / 2f
currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress) val currentCenterX = if (expansionProgress > 0f) {
currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress) lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
} else {
avatarCenterX
}
val currentCenterY = if (expansionProgress > 0f) {
lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
} else {
avatarCenterY
}
// Corner radius: круг → квадрат // Corner radius: для base size (120dp)
cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress) // В normal/collapse: из avatarState, но пересчитанный для base size
applyBlur = false // При expansion: круг → квадрат
} val cornerRadiusPx: Float = if (expansionProgress > 0f) {
collapseProgress > 0f -> { // Expansion: плавно круг → квадрат
// COLLAPSE MODE - используем avatarState.radius через scale lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
uniformScale = (avatarState.radius * 2f) / baseSizePx } else {
currentCenterX = avatarCenterX // Normal/Collapse: используем avatarState.cornerRadius
currentCenterY = avatarCenterY // Пересчитываем для base size: cornerRadius_base = cornerRadius_current / baseScale
// cornerRadius нужен относительно BASE size, поэтому делим на scale (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f)
cornerRadiusPx = avatarState.cornerRadius / uniformScale
applyBlur = avatarState.blurRadius > 0.5f
}
else -> {
// NORMAL MODE
uniformScale = 1f
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
cornerRadiusPx = baseSizePx / 2f // Полный круг
applyBlur = false
}
} }
// Blur только при collapse близко к notch
val applyBlur = expansionProgress == 0f && avatarState.blurRadius > 0.5f
// Offset: Box имеет ФИКСИРОВАННЫЙ размер, scale применяется от центра // Offset: Box имеет ФИКСИРОВАННЫЙ размер, scale применяется от центра
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
@@ -698,37 +698,34 @@ fun ProfileMetaballOverlayCompat(
val avatarCenterX = avatarState.centerX val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY val avatarCenterY = avatarState.centerY
// UNIFIED SCALE для всех режимов // 🔥 UNIFIED SCALE - БЕЗ резких переключений when
val uniformScale: Float val baseScale = (avatarState.radius * 2f) / baseSizePx
val currentCenterX: Float val targetExpansionScale = screenWidthPx / baseSizePx
val currentCenterY: Float val uniformScale = if (expansionProgress > 0f) {
val cornerRadiusPx: Float lerpFloat(baseScale, targetExpansionScale, expansionProgress)
} else {
baseScale
}.coerceAtLeast(0.01f)
when { // Центр: при expansion двигается к центру header
expansionProgress > 0f -> {
// EXPANSION MODE
val targetSize = screenWidthPx
uniformScale = lerpFloat(1f, targetSize / baseSizePx, expansionProgress)
val targetCenterX = screenWidthPx / 2f val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f val targetCenterY = headerHeightPx / 2f
currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress) val currentCenterX = if (expansionProgress > 0f) {
currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress) lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, expansionProgress) } else {
avatarCenterX
} }
collapseProgress > 0f -> { val currentCenterY = if (expansionProgress > 0f) {
// COLLAPSE MODE - используем avatarState.radius через scale lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
uniformScale = (avatarState.radius * 2f) / baseSizePx } else {
currentCenterX = avatarCenterX avatarCenterY
currentCenterY = avatarCenterY
cornerRadiusPx = avatarState.cornerRadius / uniformScale
}
else -> {
// NORMAL MODE
uniformScale = 1f
currentCenterX = avatarCenterX
currentCenterY = avatarCenterY
cornerRadiusPx = baseSizePx / 2f
} }
// 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() } val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }

View File

@@ -6,7 +6,9 @@ import androidx.activity.compose.BackHandler
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -192,7 +194,7 @@ fun OtherProfileScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
var overscrollOffset by remember { mutableFloatStateOf(0f) } var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
val snapThreshold = maxOverscroll * 0.33f // Telegram: 33% val snapThreshold = maxOverscroll * 0.05f // 🔥 5% - мгновенный snap!
// Track dragging state // Track dragging state
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -216,44 +218,51 @@ fun OtherProfileScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// SNAP ANIMATION - как Telegram's expandAnimator // SNAP ANIMATION - как Telegram's expandAnimator
// При отпускании пальца: snap к 0 или к max в зависимости от порога // При отпускании пальца: snap к 0 или к max в зависимости от порога
// ═══════════════════════════════════════════════════════════════ // 🔥 FIX: isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ
// ═══════════════════════════════════════════════════════════
val targetOverscroll = when { val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал!
isPulledDown -> maxOverscroll // После snap - держим раскрытым isDragging -> overscrollOffset // Во время drag (до порога)
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max overscrollOffset > snapThreshold -> maxOverscroll
else -> 0f // Не дотянули - snap обратно else -> 0f
} }
// Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse // 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
val snapDuration = if (targetOverscroll == maxOverscroll) { // 🔥 Плавная spring анимация для snap
((1f - currentProgress) * 150).toInt().coerceIn(50, 150)
} else {
(currentProgress * 150).toInt().coerceIn(50, 150)
}
val animatedOverscroll by animateFloatAsState( val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll, targetValue = targetOverscroll,
animationSpec = tween( animationSpec = if (isDragging && !isPulledDown) {
durationMillis = if (isDragging) 0 else snapDuration, // Без анимации во время drag (до snap)
easing = LinearOutSlowInEasing spring(stiffness = Spring.StiffnessHigh)
), } else {
// Плавная анимация для snap
spring(
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce
stiffness = Spring.StiffnessMediumLow // Плавное движение
)
},
label = "overscroll" label = "overscroll"
) )
// ExpansionProgress для передачи в overlay // ExpansionProgress для передачи в overlay
// 🔥 FIX: Когда isPulledDown=true - ВСЕГДА используем animatedOverscroll
val expansionProgress = when { 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) isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
} }
// Haptic при достижении порога (как Telegram) // Haptic при достижении порога (как Telegram)
LaunchedEffect(expansionProgress) { // 🔥 FIX: Также автоматически snap к expanded
if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) { LaunchedEffect(expansionProgress, isDragging) {
if (expansionProgress >= 0.10f && !hasTriggeredExpandHaptic && isDragging) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredExpandHaptic = true hasTriggeredExpandHaptic = true
} else if (expansionProgress < 0.2f) { // 🔥 AUTO-SNAP!
isPulledDown = true
} else if (expansionProgress < 0.02f) {
hasTriggeredExpandHaptic = false hasTriggeredExpandHaptic = false
} }
} }
@@ -484,19 +493,6 @@ private fun CollapsingOtherProfileHeader(
// Avatar font size for placeholder // Avatar font size for placeholder
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress) 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 // Text animation - always centered
val textDefaultY = expandedHeight - 48.dp val textDefaultY = expandedHeight - 48.dp
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2 val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2

View File

@@ -339,7 +339,7 @@ fun ProfileScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
var overscrollOffset by remember { mutableFloatStateOf(0f) } var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
val snapThreshold = maxOverscroll * 0.33f // Telegram: 33% val snapThreshold = maxOverscroll * 0.05f // 🔥 5% - мгновенный snap!
// Track dragging state // Track dragging state
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -357,45 +357,55 @@ fun ProfileScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// SNAP ANIMATION - как Telegram's expandAnimator // SNAP ANIMATION - как Telegram's expandAnimator
// При отпускании пальца: snap к 0 или к max в зависимости от порога // При отпускании пальца: snap к 0 или к max в зависимости от порога
// 🔥 FIX: Если isPulledDown=true - ВСЕГДА snap к max (даже во время drag!)
// isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ - игнорирует isDragging
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
val targetOverscroll = when { val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал - держим раскрытым!
isPulledDown -> maxOverscroll // После snap - держим раскрытым isDragging -> overscrollOffset // Во время drag (до порога) - следуем за пальцем
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
else -> 0f // Не дотянули - snap обратно else -> 0f // Не дотянули - snap обратно
} }
// Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse // 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ
// Но минимизируем для мгновенного отклика // чтобы аватарка сразу заполнилась после порога
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) 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( val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll, targetValue = targetOverscroll,
animationSpec = tween( animationSpec = if (isDragging && !isPulledDown) {
durationMillis = if (isDragging) 0 else snapDuration, // Без анимации во время drag (до snap)
easing = LinearOutSlowInEasing // Быстрый старт, плавное завершение spring(stiffness = Spring.StiffnessHigh)
), } else {
// Плавная анимация для snap
spring(
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce
stiffness = Spring.StiffnessMediumLow // Плавное движение
)
},
label = "overscroll" label = "overscroll"
) )
// ExpansionProgress для передачи в overlay // ExpansionProgress для передачи в overlay
// 🔥 FIX: Когда isPulledDown=true - ВСЕГДА используем animatedOverscroll
// чтобы аватарка автоматически расширилась до конца
val expansionProgress = when { val expansionProgress = when {
collapseProgress > 0.1f -> 0f // Не расширяем при collapse collapseProgress > 0.1f -> 0f // Не расширяем при collapse
isPulledDown -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // 🔥 AUTO-EXPAND!
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
} }
// Haptic при достижении порога (как Telegram) // Haptic при достижении порога (как Telegram)
LaunchedEffect(expansionProgress) { // 🔥 FIX: Также автоматически snap к expanded при достижении порога
if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) { LaunchedEffect(expansionProgress, isDragging) {
if (expansionProgress >= 0.10f && !hasTriggeredExpandHaptic && isDragging) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredExpandHaptic = true hasTriggeredExpandHaptic = true
} else if (expansionProgress < 0.2f) { // 🔥 AUTO-SNAP: После порога автоматически snap к expanded
isPulledDown = true
} else if (expansionProgress < 0.02f) {
hasTriggeredExpandHaptic = false hasTriggeredExpandHaptic = false
} }
} }
@@ -853,19 +863,6 @@ private fun CollapsingProfileHeader(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress) 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 зоны, внутри блока // 📝 TEXT - внизу header зоны, внутри блока
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════