feat: implement avatar expansion and collapse logic with smooth snapping in ProfileScreen

This commit is contained in:
2026-02-01 14:07:42 +05:00
parent 4f26aaa887
commit 832227cf1c
2 changed files with 392 additions and 159 deletions

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
@@ -290,12 +291,13 @@ fun ProfileScreen(
val maxScrollOffset = expandedHeightPx - collapsedHeightPx
// Track scroll offset for collapsing (скролл вверх = collapse)
// Telegram: extraHeight - напрямую от позиции первого элемента
var scrollOffset by remember { mutableFloatStateOf(0f) }
// Calculate collapse progress (0 = expanded, 1 = collapsed)
val collapseProgress by remember {
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) }
}
// Telegram: diff = (extraHeight - actionsHeight) / headerOnlyExtraHeight
// Напрямую без derivedStateOf для плавности
val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f)
// Dynamic header height based on scroll
val headerHeight =
@@ -304,40 +306,46 @@ fun ProfileScreen(
}
// Track overscroll offset for avatar expansion (скролл вниз при достижении верха)
// Telegram: когда extraHeight > headerExtraHeight - это isPulledDown
var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 200.dp.toPx() }
val snapThreshold = maxOverscroll * 0.5f
val snapThreshold = maxOverscroll * 0.33f // Telegram: expandProgress >= 0.33f
// Track if user is currently dragging
var isDragging by remember { mutableStateOf(false) }
// Track if fully expanded (snapped to full)
var isFullyExpanded by remember { mutableStateOf(false) }
// Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат)
val snapTarget by remember {
derivedStateOf {
if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll
else if (!isDragging) 0f else overscrollOffset
}
// Smooth snap animation - только когда отпустили палец
// Telegram использует CubicBezierInterpolator.EASE_BOTH для snap
val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую
isFullyExpanded -> maxOverscroll // Зафиксировано в раскрытом
overscrollOffset > snapThreshold -> maxOverscroll // Snap к раскрытому
else -> 0f // Snap к закрытому
}
// Быстрая snap-анимация без застревания
val animatedOverscroll by
animateFloatAsState(
targetValue = snapTarget,
animationSpec =
spring(
dampingRatio = Spring.DampingRatioMediumBouncy, // Меньше пружинистости
stiffness = 4000f // Очень быстрый snap
),
label = "overscroll"
)
val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll,
animationSpec = tween(
durationMillis = if (isDragging) 0 else 250, // Мгновенно при drag, плавно при snap
easing = FastOutSlowInEasing // Как Telegram's EASE_BOTH
),
label = "overscroll"
)
// Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт
val expansionProgress by remember {
derivedStateOf {
if (collapseProgress > 0.1f) 0f // Не расширяем если header коллапсирован
else (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
}
// Telegram: expandProgress = (extraHeight - headerExtraHeight) / (listWidth - actionBarHeight - headerOnlyExtraHeight)
val expansionProgress = when {
collapseProgress > 0.1f -> 0f // Не расширяем если header коллапсирован
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) // Напрямую при drag
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // Анимированно при snap
}
// DEBUG LOGS
Log.d("ProfileScroll", "scrollOffset=$scrollOffset, collapseProgress=$collapseProgress")
Log.d("ProfileScroll", "overscrollOffset=$overscrollOffset, expansionProgress=$expansionProgress, isDragging=$isDragging")
// Nested scroll connection для collapsing + overscroll
val nestedScrollConnection = remember {
@@ -353,6 +361,10 @@ fun ProfileScreen(
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset
// Если вышли из fully expanded - сбросить флаг
if (overscrollOffset < maxOverscroll * 0.9f) {
isFullyExpanded = false
}
return Offset(0f, -consumed)
}
// Затем коллапсируем header
@@ -382,7 +394,8 @@ fun ProfileScreen(
): Offset {
// Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll
if (available.y > 0 && scrollOffset == 0f) {
val resistance = 0.5f // Легче тянуть (было 0.3f)
// Telegram: if (!isPulledDown) dy /= 2
val resistance = if (isFullyExpanded) 1f else 0.5f
val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
return Offset(0f, available.y)
@@ -392,6 +405,10 @@ fun ProfileScreen(
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false
// Если перешли порог - зафиксировать как fully expanded
if (overscrollOffset > snapThreshold) {
isFullyExpanded = true
}
return Velocity.Zero
}
}