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.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 используем меньшую сторону
|
||||||
|
|||||||
Reference in New Issue
Block a user