feat: implement avatar expansion and collapse logic with smooth snapping in ProfileScreen

This commit is contained in:
k1ngsterr1
2026-02-01 14:07:42 +05:00
parent 4f26aaa887
commit 832227cf1c
2 changed files with 392 additions and 159 deletions

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.Path
import android.util.Log
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.graphics.Shader
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.intellij.lang.annotations.Language
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
import androidx.compose.ui.graphics.Color as ComposeColor
@@ -40,12 +42,43 @@ import androidx.compose.ui.graphics.Color as ComposeColor
/**
* Constants for the Profile Metaball Animation
* Based on Telegram's ProfileMetaballView implementation
*
* THRESHOLDS from Telegram:
* - isDrawing: vr <= 40dp (start metaball effect)
* - isNear: vr <= 32dp (avatar very close to notch)
* - Form transition: 40dp → 34dp (circle → rounded rect)
* - Alpha fade: 32dp → 18dp (1.0 → 0.0)
*
* SCALE from Telegram:
* - Collapsed (into notch): avatarScale = lerp(24dp, 96dp, diff) / 100f
* - Expanded (pull-down): avatarScale = lerp(96/42, 138/42, expandProgress*3) / 100f * 42
*
* POSITION from Telegram:
* - avatarY = lerp(endY, startY, diff)
* - endY = notch center or -dp(29)
* - startY = statusBar + actionBarHeight - dp(21)
*/
object ProfileMetaballConstants {
// Avatar dimensions
// Avatar dimensions (like Telegram: collapsed into 24dp, normal 96dp, expanded 138dp)
val AVATAR_SIZE_COLLAPSED = 24.dp // intoSize when fully collapsed into notch
val AVATAR_SIZE_NORMAL = 96.dp // Normal expanded avatar size
val AVATAR_SIZE_PULLED = 138.dp // When pulled down (isPulledDown)
// Legacy - for compatibility
val AVATAR_SIZE_EXPANDED = 120.dp
val AVATAR_SIZE_MIN = 24.dp
// Telegram thresholds (in dp)
val THRESHOLD_DRAWING = 40.dp // isDrawing = vr <= 40dp
val THRESHOLD_NEAR = 32.dp // isNear = vr <= 32dp
val THRESHOLD_SHAPE_START = 40.dp // Start shape transition
val THRESHOLD_SHAPE_END = 34.dp // End shape transition (fully rounded rect)
val THRESHOLD_ALPHA_START = 32.dp // Start alpha fade
val THRESHOLD_ALPHA_END = 18.dp // End alpha fade (fully transparent)
// Corner radius for rounded rect form (like Telegram's dp(22))
val ROUNDED_RECT_RADIUS = 22.dp
// Animation thresholds
const val MERGE_START_PROGRESS = 0.5f
const val MERGE_COMPLETE_PROGRESS = 0.95f
@@ -54,8 +87,15 @@ object ProfileMetaballConstants {
const val BLUR_RADIUS = 15f
const val CUTOFF = 0.5f
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
const val BLUR_MIN = 2f
const val BLUR_MAX = 22f
// Fallback camera size if no notch detected (like status bar rect in Telegram)
val FALLBACK_CAMERA_SIZE = 12.dp
// Y position offset when fully collapsed (Telegram: -dp(29))
val COLLAPSED_Y_OFFSET = (-29).dp
}
/**
@@ -84,75 +124,181 @@ private const val ProfileMetaballShaderSource = """
/**
* State for avatar position and size during animation
* Like Telegram's ProfileMetaballView state variables
*/
private data class AvatarState(
val centerX: Float,
val centerY: Float,
val radius: Float,
val opacity: Float,
val showBlob: Boolean
val radius: Float, // vr in Telegram
val opacity: Float, // alpha / 255f
val showBlob: Boolean,
val isDrawing: Boolean, // vr <= 40dp
val isNear: Boolean, // vr <= 32dp
val cornerRadius: Float, // For rounded rect transition
val blurRadius: Float // Blur amount when near
)
/**
* Compute avatar state based on collapse progress
* Exact logic from Telegram's ProfileMetaballView.onDraw() and needLayout()
*
* Key formulas from Telegram:
* - avatarScale = lerp(intoSize, 96, diff) / 100f (where intoSize = 24 or notch width/2)
* - avatarY = lerp(endY, startY, diff) (endY = notch center, startY = statusBar + actionBar - dp(21))
* - vr = view.getWidth() * view.getScaleX() * 0.5f (half of scaled avatar width)
*/
private fun computeAvatarState(
collapseProgress: Float,
expansionProgress: Float,
collapseProgress: Float, // 0 = expanded, 1 = collapsed into notch
expansionProgress: Float, // 0 = normal, 1 = pulled down to full screen
screenWidthPx: Float,
statusBarHeightPx: Float,
headerHeightPx: Float,
avatarSizeExpandedPx: Float,
avatarSizeMinPx: Float,
hasAvatar: Boolean
avatarSizeExpandedPx: Float, // Normal avatar size (96dp in Telegram terms)
avatarSizeMinPx: Float, // Into notch size (24dp or notch width)
hasAvatar: Boolean,
// Notch info
notchCenterY: Float,
notchRadiusPx: Float,
// Telegram thresholds in pixels
dp40: Float,
dp34: Float,
dp32: Float,
dp18: Float,
dp22: Float, // Corner radius for rounded rect
fullCornerRadius: Float // Full circle corner radius
): AvatarState {
val sharpExpansion = sqrt(expansionProgress.toDouble()).toFloat()
// ═══════════════════════════════════════════════════════════════
// TELEGRAM LOGIC: diff = 1 - collapseProgress (for us)
// diff = how "expanded" the avatar is (1 = fully expanded, 0 = collapsed)
// ═══════════════════════════════════════════════════════════════
val diff = 1f - collapseProgress
// Calculate radius
// ═══════════════════════════════════════════════════════════════
// RADIUS (vr in Telegram)
// Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
// Then: vr = view.getWidth() * avatarScale * 0.5f
// For us: view.getWidth() = avatarSizeExpandedPx (base size)
// ═══════════════════════════════════════════════════════════════
val radius: Float = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
// Expanding to full screen
val expandedRadius = screenWidthPx / 2f
lerpFloat(avatarSizeExpandedPx / 2f, expandedRadius, sharpExpansion)
// Pull-down expansion (like Telegram isPulledDown)
hasAvatar && expansionProgress > 0f -> {
// Telegram: avatarScale = lerp(96/42, 138/42, min(1, expandProgress*3)) / 100f * 42f
val expandScale = lerpFloat(
avatarSizeExpandedPx / 2f, // Normal radius
screenWidthPx / 2f, // Full screen width / 2
expansionProgress
)
expandScale
}
// Collapsing into notch
collapseProgress > 0f -> {
// Collapsing - shrink avatar
val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f)
lerpFloat(avatarSizeExpandedPx / 2f, avatarSizeMinPx / 2f, shrinkProgress)
// Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
// vr = baseWidth * avatarScale * 0.5
val intoSize = notchRadiusPx * 2f // Target size = notch diameter
val normalSize = avatarSizeExpandedPx
val avatarWidth = lerpFloat(intoSize, normalSize, diff)
avatarWidth / 2f // radius = half of width
}
// Normal state
else -> avatarSizeExpandedPx / 2f
}
// Center X is always screen center
// ═══════════════════════════════════════════════════════════════
// Telegram thresholds
// ═══════════════════════════════════════════════════════════════
val isDrawing = radius <= dp40
val isNear = radius <= dp32
// ═══════════════════════════════════════════════════════════════
// CENTER X - always screen center
// ═══════════════════════════════════════════════════════════════
val centerX = screenWidthPx / 2f
// Calculate Y position
val defaultCenterY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f
val targetY = statusBarHeightPx / 2f // Target: center of status bar (camera area)
// ═══════════════════════════════════════════════════════════════
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
// endY = notch center (or -dp(29) if no notch)
// startY = statusBarHeight + actionBarHeight - dp(21) + some offset
// ═══════════════════════════════════════════════════════════════
val startY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f // Normal center
val endY = notchCenterY // Target = notch center
val centerY: Float = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
lerpFloat(defaultCenterY, headerHeightPx / 2f, sharpExpansion)
// Pull-down expansion
hasAvatar && expansionProgress > 0f -> {
lerpFloat(startY, headerHeightPx / 2f, expansionProgress)
}
// Collapsing - animate Y towards notch
else -> {
lerpFloat(defaultCenterY, targetY, collapseProgress)
lerpFloat(endY, startY, diff)
}
}
// Opacity
val opacity: Float = when {
collapseProgress < ProfileMetaballConstants.MERGE_START_PROGRESS -> 1f
collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f
else -> {
val mergeProgress = (collapseProgress - ProfileMetaballConstants.MERGE_START_PROGRESS) /
(ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS - ProfileMetaballConstants.MERGE_START_PROGRESS)
(1f - mergeProgress).coerceIn(0f, 1f)
// ═══════════════════════════════════════════════════════════════
// CORNER RADIUS - Shape transition
// Telegram: аватарка остаётся КРУГЛОЙ пока тянешь!
// Квадратной становится только при полном раскрытии (isPulledDown)
// Переход круг→квадрат начинается при expansionProgress > 0.8
// ═══════════════════════════════════════════════════════════════
val cornerRadius: Float = when {
// EXPANDED - переход к квадрату начинается только после 80% expansion
expansionProgress > 0f -> {
// Telegram: квадрат только при полном раскрытии
// До 80% - остаётся круглым, после 80% - быстро переходит в квадрат
val squareProgress = ((expansionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
lerpFloat(fullCornerRadius, 0f, squareProgress)
}
// COLLAPSING - переход круг → rounded rect (like Telegram)
isDrawing -> {
// Telegram: lerp(dp(22), radius, clamp01((vr - dp(34)) / dp(6)))
val shapeProgress = ((radius - dp34) / (dp40 - dp34)).coerceIn(0f, 1f)
val roundRadiusCollapse = lerpFloat(dp22, fullCornerRadius, shapeProgress)
// Telegram: scaledRadius = vr / dp(22) * roundRadiusCollapse
(radius / dp22) * roundRadiusCollapse
}
// NORMAL - полный круг
else -> fullCornerRadius
}
// ═══════════════════════════════════════════════════════════════
// OPACITY - Telegram: alpha changes from 1 to 0 when vr 32dp→18dp
// float fraction = Math.max(0f, vr - dp(18)) / dp(32 - 18);
// float alphaFraction = lerp(0f, 1f, fraction);
// ═══════════════════════════════════════════════════════════════
val opacity: Float = if (isNear) {
val fraction = max(0f, radius - dp18) / (dp32 - dp18)
lerpFloat(0f, 1f, fraction)
} else {
1f
}
// ═══════════════════════════════════════════════════════════════
// BLUR RADIUS - Telegram: 2 + (1 - fraction) * 20
// More blur as avatar gets closer to notch
// ═══════════════════════════════════════════════════════════════
val blurRadius: Float = if (isNear) {
val fraction = max(0f, radius - dp18) / (dp32 - dp18)
ProfileMetaballConstants.BLUR_MIN + (1f - fraction) * (ProfileMetaballConstants.BLUR_MAX - ProfileMetaballConstants.BLUR_MIN)
} else {
0f
}
val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && radius > 1f
return AvatarState(centerX, centerY, radius, opacity, showBlob)
// DEBUG LOG
Log.d("Metaball", "collapse=$collapseProgress, expansion=$expansionProgress, radius=$radius, diff=$diff")
Log.d("Metaball", "centerY=$centerY, cornerRadius=$cornerRadius, isDrawing=$isDrawing, isNear=$isNear")
return AvatarState(
centerX = centerX,
centerY = centerY,
radius = radius,
opacity = opacity,
showBlob = showBlob,
isDrawing = isDrawing,
isNear = isNear,
cornerRadius = cornerRadius,
blurRadius = blurRadius
)
}
private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
@@ -226,8 +372,15 @@ fun ProfileMetaballOverlay(
}
}
// Avatar state
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) {
// Telegram thresholds in pixels
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
// Avatar state - computed with Telegram's exact logic
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar, notchCenterY, notchRadiusPx) {
derivedStateOf {
computeAvatarState(
collapseProgress = collapseProgress,
@@ -237,7 +390,15 @@ fun ProfileMetaballOverlay(
headerHeightPx = headerHeightPx,
avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar
hasAvatar = hasAvatar,
notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx,
dp40 = dp40,
dp34 = dp34,
dp32 = dp32,
dp18 = dp18,
dp22 = dp22,
fullCornerRadius = avatarSizeExpandedPx / 2f // Full circle = radius
)
}
}
@@ -255,32 +416,29 @@ fun ProfileMetaballOverlay(
val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx
val c = (distance / maxDist).coerceIn(-1f, 1f)
val v = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
// Like Telegram: isDrawing = vr <= dp(40), isNear = vr <= dp(32)
// Avatar radius thresholds (in px)
val dp40 = with(density) { 40.dp.toPx() }
val dp32 = with(density) { 32.dp.toPx() }
// Should we draw the metaball effect? (when avatar is small enough)
val isDrawing = avatarState.radius <= dp40
val isNear = avatarState.radius <= dp32
// Show connector only when avatar is close enough AND small enough
val showConnector = isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// Calculate blur intensity for avatar based on distance to notch (like Telegram)
// When avatar is far - no blur, when close - more blur
val avatarBlurRadius = if (showConnector) {
// More blur as avatar gets closer to notch
val nearProgress = (1f - (distance / maxDist).coerceIn(0f, 1f))
(nearProgress * ProfileMetaballConstants.BLUR_RADIUS * 0.5f).coerceIn(0f, 10f)
// Like Telegram: when NOT near, reduce v
// float near = isNear ? 1f : 1f - (vr - dp(32)) / dp(40 - 32);
// v = Math.min(lerp(0f, 0.2f, near), v);
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
val v = if (!avatarState.isNear) {
min(lerpFloat(0f, 0.2f, near), baseV)
} else {
0f
baseV
}
// Show connector only when avatar is small enough (like Telegram isDrawing)
// AND not when expanding (no metaball effect when expanded)
val showConnector = expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// Don't show black metaball shapes when expanded
val showMetaballLayer = expansionProgress == 0f
Box(modifier = modifier.fillMaxSize()) {
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
// HIDDEN when expanded - only show avatar content
if (showMetaballLayer) {
Canvas(
modifier = Modifier
.fillMaxSize()
@@ -336,14 +494,23 @@ fun ProfileMetaballOverlay(
}
}
// Draw avatar circle
// Draw avatar shape - circle or rounded rect depending on state
if (avatarState.showBlob) {
nativeCanvas.drawCircle(
avatarState.centerX,
avatarState.centerY,
avatarState.radius,
paint
)
val cx = avatarState.centerX
val cy = avatarState.centerY
val r = avatarState.radius
val cornerR = avatarState.cornerRadius
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
if (cornerR >= r * 0.95f) {
// Draw circle
nativeCanvas.drawCircle(cx, cy, r, paint)
} else {
// Draw rounded rect (like Telegram when isDrawing)
android.graphics.RectF(cx - r, cy - r, cx + r, cy + r).let { rect ->
nativeCanvas.drawRoundRect(rect, cornerR, cornerR, paint)
}
}
}
// Draw metaball connector path
@@ -359,26 +526,43 @@ fun ProfileMetaballOverlay(
}
}
}
} // END if (showMetaballLayer)
// LAYER 2: Actual avatar content - with blur when close to notch
// LAYER 2: Actual avatar content - with blur and shape transition like Telegram
if (avatarState.showBlob) {
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() }
// При раскрытии - занимаем всю ширину экрана и всю высоту header'а
val isExpanded = expansionProgress > 0f
val avatarWidthDp = with(density) {
if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp()
}
val avatarHeightDp = with(density) {
if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp()
}
val avatarOffsetX = with(density) {
if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp()
}
val avatarOffsetY = with(density) {
if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp()
}
// Corner radius from state - transitions from circle to rounded rect
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(avatarSizeDp / 2))
.width(avatarWidthDp)
.height(avatarHeightDp)
// Use dynamic corner radius - circle → rounded rect transition
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
// Apply blur to avatar when close to notch (like Telegram)
if (avatarBlurRadius > 0.5f) {
// Apply blur to avatar when near notch (like Telegram)
// Telegram: blurRadius = 2 + (1 - fraction) * 20
if (avatarState.blurRadius > 0.5f) {
renderEffect = RenderEffect.createBlurEffect(
avatarBlurRadius,
avatarBlurRadius,
avatarState.blurRadius,
avatarState.blurRadius,
Shader.TileMode.DECAL
).asComposeRenderEffect()
}
@@ -392,6 +576,7 @@ fun ProfileMetaballOverlay(
/**
* Compat version for older Android - simple animation without metaball effect
* Still includes shape transition (circle → rounded rect) but no blur/gooey effects
*/
@Composable
fun ProfileMetaballOverlayCompat(
@@ -414,6 +599,17 @@ fun ProfileMetaballOverlayCompat(
val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() }
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
// Fallback notch values for compat mode
val notchCenterY = statusBarHeightPx / 2f
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
// Telegram thresholds in pixels
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) {
derivedStateOf {
computeAvatarState(
@@ -424,23 +620,43 @@ fun ProfileMetaballOverlayCompat(
headerHeightPx = headerHeightPx,
avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar
hasAvatar = hasAvatar,
notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx,
dp40 = dp40,
dp34 = dp34,
dp32 = dp32,
dp18 = dp18,
dp22 = dp22,
fullCornerRadius = avatarSizeExpandedPx / 2f
)
}
}
Box(modifier = modifier.fillMaxSize()) {
if (avatarState.showBlob) {
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() }
// При раскрытии - занимаем всю ширину экрана и всю высоту header'а
val isExpanded = expansionProgress > 0f
val avatarWidthDp = with(density) {
if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp()
}
val avatarHeightDp = with(density) {
if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp()
}
val avatarOffsetX = with(density) {
if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp()
}
val avatarOffsetY = with(density) {
if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp()
}
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(avatarSizeDp / 2))
.width(avatarWidthDp)
.height(avatarHeightDp)
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
},

View File

@@ -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,41 +306,47 @@ 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) }
// Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат)
val snapTarget by remember {
derivedStateOf {
if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll
else if (!isDragging) 0f else overscrollOffset
}
// Track if fully expanded (snapped to full)
var isFullyExpanded by remember { mutableStateOf(false) }
// 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
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 {
object : NestedScrollConnection {
@@ -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
}
}