feat: implement avatar expansion and collapse logic with smooth snapping in ProfileScreen
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user