feat: Enhance ProfileScreen with improved overscroll handling and animation for avatar transition
This commit is contained in:
@@ -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 используем меньшую сторону
|
||||
|
||||
Reference in New Issue
Block a user