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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@@ -300,16 +301,69 @@ fun ProfileScreen(
// Может быть отрицательным для overscroll (оттягивание вверх) // Может быть отрицательным для overscroll (оттягивание вверх)
var scrollOffset by remember { mutableFloatStateOf(0f) } var scrollOffset by remember { mutableFloatStateOf(0f) }
val maxScrollOffset = expandedHeightPx - collapsedHeightPx 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) // Calculate collapse progress (0 = expanded, 1 = collapsed, negative = overscroll)
val collapseProgress by remember { 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 { 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 // Nested scroll connection for tracking scroll with overscroll support
@@ -318,28 +372,51 @@ fun ProfileScreen(
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y val delta = available.y
val newOffset = scrollOffset - delta 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 = val consumed =
when { when {
// Scroll up (collapse) // Scroll up (collapse) - delta < 0 = палец идёт вверх
delta < 0 && scrollOffset < maxScrollOffset -> { delta < 0 && scrollOffset < maxScrollOffset -> {
val consumed = val consumed =
(newOffset.coerceIn(minScrollOffset, maxScrollOffset) - (newOffset.coerceIn(minScrollOffset, maxScrollOffset) -
scrollOffset) scrollOffset)
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset) scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
Log.d(TAG, "📈 Collapse: scrollOffset=$scrollOffset")
-consumed -consumed
} }
// Scroll down (expand / overscroll) // Scroll down (expand / overscroll) - delta > 0 = палец идёт вниз
delta > 0 && scrollOffset > minScrollOffset -> { delta > 0 && scrollOffset > minScrollOffset -> {
val consumed = val consumed =
scrollOffset - scrollOffset -
newOffset.coerceIn(minScrollOffset, maxScrollOffset) newOffset.coerceIn(minScrollOffset, maxScrollOffset)
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset) scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
Log.d(TAG, "📉 Expand/Overscroll: scrollOffset=$scrollOffset")
consumed consumed
} }
else -> 0f else -> 0f
} }
return Offset(0f, consumed) 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 = top =
with(density) { with(density) {
// Не увеличиваем padding при overscroll // Не увеличиваем padding при overscroll
// (scrollOffset < 0) // Используем animatedScrollOffset для плавности
(expandedHeightPx - (expandedHeightPx -
scrollOffset.coerceAtLeast(0f)) animatedScrollOffset.value
.coerceAtLeast(0f))
.toDp() .toDp()
} }
) )
@@ -714,19 +792,12 @@ private fun CollapsingProfileHeader(
val collapseOnly = collapseProgress.coerceAtLeast(0f) val collapseOnly = collapseProgress.coerceAtLeast(0f)
if (overscrollProgress > 0f) { if (overscrollProgress > 0f) {
// OVERSCROLL: аватар становится квадратным и занимает весь header // OVERSCROLL: размер СРАЗУ полный, закругление плавно уменьшается
// Header остаётся фиксированным = expandedHeight avatarWidth = screenWidthDp
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, overscrollProgress) avatarHeight = expandedHeight
avatarHeight = androidx.compose.ui.unit.lerp(circleSize, expandedHeight, overscrollProgress) avatarX = 0.dp
avatarX = avatarY = 0.dp
androidx.compose.ui.unit.lerp( // Закругление плавно от круга (circleSize/2) до квадрата (0)
(screenWidthDp - circleSize) / 2,
0.dp,
overscrollProgress
)
val avatarCenterY = (expandedHeight - circleSize) / 2
avatarY = androidx.compose.ui.unit.lerp(avatarCenterY, 0.dp, overscrollProgress)
// Закругление: от круга до квадрата
cornerRadius = androidx.compose.ui.unit.lerp(circleSize / 2, 0.dp, overscrollProgress) cornerRadius = androidx.compose.ui.unit.lerp(circleSize / 2, 0.dp, overscrollProgress)
} else { } else {
// NORMAL / COLLAPSE: круглый аватар уменьшается при скролле // NORMAL / COLLAPSE: круглый аватар уменьшается при скролле
@@ -751,8 +822,8 @@ private fun CollapsingProfileHeader(
phase2Progress phase2Progress
) )
} }
// Всегда круглый // Полностью круглый
cornerRadius = avatarWidth / 2 cornerRadius = circleSize / 2
} }
// Для cornerRadius используем меньшую сторону // Для cornerRadius используем меньшую сторону