feat: Enhance ProfileScreen with improved overscroll handling and animation for avatar transition

This commit is contained in:
k1ngsterr1
2026-01-30 19:23:46 +05:00
parent 976a9d7ab2
commit 489820eae6

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
@@ -300,16 +301,69 @@ fun ProfileScreen(
// Может быть отрицательным для overscroll (оттягивание вверх)
var scrollOffset by remember { mutableFloatStateOf(0f) }
val maxScrollOffset = expandedHeightPx - collapsedHeightPx
val minScrollOffset = -expandedHeightPx * 0.3f // Максимальный overscroll (30% высоты)
val minScrollOffset = -expandedHeightPx * 0.5f // Максимальный overscroll (50% высоты)
// Порог для snap - если overscroll > 50%, то snap к квадрату, иначе к кругу
val snapThreshold = minScrollOffset * 0.5f
// Анимированный overscroll offset для плавного snap
val animatedScrollOffset = remember { androidx.compose.animation.core.Animatable(0f) }
// Отслеживаем активный drag
var isDragging by remember { mutableStateOf(false) }
// Calculate collapse progress (0 = expanded, 1 = collapsed, negative = overscroll)
val collapseProgress by remember {
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(-1f, 1f) }
derivedStateOf { (animatedScrollOffset.value / maxScrollOffset).coerceIn(-1f, 1f) }
}
// Overscroll progress (0 = normal, 1 = full overscroll - квадратный аватар)
// Overscroll progress - просто на основе scrollOffset
val overscrollProgress by remember {
derivedStateOf { (-scrollOffset / (-minScrollOffset)).coerceIn(0f, 1f) }
derivedStateOf {
val progress =
if (animatedScrollOffset.value < 0f) {
(-animatedScrollOffset.value / (-minScrollOffset)).coerceIn(0f, 1f)
} else {
0f
}
Log.d(
TAG,
"📊 overscrollProgress=$progress, scrollOffset=${animatedScrollOffset.value}, minScrollOffset=$minScrollOffset"
)
progress
}
}
// Snap анимация при отпускании
LaunchedEffect(isDragging) {
if (!isDragging && scrollOffset < 0f) {
// Отпустили палец в overscroll зоне - snap к ближайшему состоянию
val targetOffset =
if (scrollOffset < snapThreshold) {
minScrollOffset // Snap к квадрату
} else {
0f // Snap к кругу
}
Log.d(TAG, "🎯 Snap animation: from $scrollOffset to $targetOffset")
animatedScrollOffset.animateTo(
targetValue = targetOffset,
animationSpec =
spring(
dampingRatio =
androidx.compose.animation.core.Spring
.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessLow
)
)
scrollOffset = targetOffset
}
}
// Синхронизация scrollOffset -> animatedScrollOffset во время drag
LaunchedEffect(scrollOffset, isDragging) {
if (isDragging) {
animatedScrollOffset.snapTo(scrollOffset)
}
}
// Nested scroll connection for tracking scroll with overscroll support
@@ -318,28 +372,51 @@ fun ProfileScreen(
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = scrollOffset - delta
isDragging = true
Log.d(
TAG,
"🔄 onPreScroll: delta=$delta, scrollOffset=$scrollOffset, newOffset=$newOffset"
)
// Если скроллим вверх (delta < 0) и были в overscroll - плавно возвращаемся
if (delta < -5f && scrollOffset < 0f) {
Log.d(TAG, "⚡ Scroll up from overscroll: scrollOffset=$scrollOffset")
scrollOffset = (scrollOffset - delta * 0.5f).coerceIn(minScrollOffset, 0f)
return Offset(0f, -delta)
}
val consumed =
when {
// Scroll up (collapse)
// Scroll up (collapse) - delta < 0 = палец идёт вверх
delta < 0 && scrollOffset < maxScrollOffset -> {
val consumed =
(newOffset.coerceIn(minScrollOffset, maxScrollOffset) -
scrollOffset)
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
Log.d(TAG, "📈 Collapse: scrollOffset=$scrollOffset")
-consumed
}
// Scroll down (expand / overscroll)
// Scroll down (expand / overscroll) - delta > 0 = палец идёт вниз
delta > 0 && scrollOffset > minScrollOffset -> {
val consumed =
scrollOffset -
newOffset.coerceIn(minScrollOffset, maxScrollOffset)
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
Log.d(TAG, "📉 Expand/Overscroll: scrollOffset=$scrollOffset")
consumed
}
else -> 0f
}
return Offset(0f, consumed)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false
Log.d(TAG, "👆 onPostFling: drag ended, isDragging=false")
return super.onPostFling(consumed, available)
}
}
}
@@ -399,9 +476,10 @@ fun ProfileScreen(
top =
with(density) {
// Не увеличиваем padding при overscroll
// (scrollOffset < 0)
// Используем animatedScrollOffset для плавности
(expandedHeightPx -
scrollOffset.coerceAtLeast(0f))
animatedScrollOffset.value
.coerceAtLeast(0f))
.toDp()
}
)
@@ -714,19 +792,12 @@ private fun CollapsingProfileHeader(
val collapseOnly = collapseProgress.coerceAtLeast(0f)
if (overscrollProgress > 0f) {
// OVERSCROLL: аватар становится квадратным и занимает весь header
// Header остаётся фиксированным = expandedHeight
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, overscrollProgress)
avatarHeight = androidx.compose.ui.unit.lerp(circleSize, expandedHeight, overscrollProgress)
avatarX =
androidx.compose.ui.unit.lerp(
(screenWidthDp - circleSize) / 2,
0.dp,
overscrollProgress
)
val avatarCenterY = (expandedHeight - circleSize) / 2
avatarY = androidx.compose.ui.unit.lerp(avatarCenterY, 0.dp, overscrollProgress)
// Закругление: от круга до квадрата
// OVERSCROLL: размер СРАЗУ полный, закругление плавно уменьшается
avatarWidth = screenWidthDp
avatarHeight = expandedHeight
avatarX = 0.dp
avatarY = 0.dp
// Закругление плавно от круга (circleSize/2) до квадрата (0)
cornerRadius = androidx.compose.ui.unit.lerp(circleSize / 2, 0.dp, overscrollProgress)
} else {
// NORMAL / COLLAPSE: круглый аватар уменьшается при скролле
@@ -751,8 +822,8 @@ private fun CollapsingProfileHeader(
phase2Progress
)
}
// Всегда круглый
cornerRadius = avatarWidth / 2
// Полностью круглый
cornerRadius = circleSize / 2
}
// Для cornerRadius используем меньшую сторону