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 package com.rosetta.messenger.ui.components.metaball
import android.graphics.Path import android.graphics.Path
import android.util.Log
import android.graphics.RenderEffect import android.graphics.RenderEffect
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.graphics.Shader import android.graphics.Shader
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.sqrt import kotlin.math.sqrt
import androidx.compose.ui.graphics.Color as ComposeColor 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 * Constants for the Profile Metaball Animation
* Based on Telegram's ProfileMetaballView implementation * 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 { 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_EXPANDED = 120.dp
val AVATAR_SIZE_MIN = 24.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 // Animation thresholds
const val MERGE_START_PROGRESS = 0.5f const val MERGE_START_PROGRESS = 0.5f
const val MERGE_COMPLETE_PROGRESS = 0.95f const val MERGE_COMPLETE_PROGRESS = 0.95f
@@ -54,8 +87,15 @@ object ProfileMetaballConstants {
const val BLUR_RADIUS = 15f const val BLUR_RADIUS = 15f
const val CUTOFF = 0.5f 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) // Fallback camera size if no notch detected (like status bar rect in Telegram)
val FALLBACK_CAMERA_SIZE = 12.dp 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 * State for avatar position and size during animation
* Like Telegram's ProfileMetaballView state variables
*/ */
private data class AvatarState( private data class AvatarState(
val centerX: Float, val centerX: Float,
val centerY: Float, val centerY: Float,
val radius: Float, val radius: Float, // vr in Telegram
val opacity: Float, val opacity: Float, // alpha / 255f
val showBlob: Boolean 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 * 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( private fun computeAvatarState(
collapseProgress: Float, collapseProgress: Float, // 0 = expanded, 1 = collapsed into notch
expansionProgress: Float, expansionProgress: Float, // 0 = normal, 1 = pulled down to full screen
screenWidthPx: Float, screenWidthPx: Float,
statusBarHeightPx: Float, statusBarHeightPx: Float,
headerHeightPx: Float, headerHeightPx: Float,
avatarSizeExpandedPx: Float, avatarSizeExpandedPx: Float, // Normal avatar size (96dp in Telegram terms)
avatarSizeMinPx: Float, avatarSizeMinPx: Float, // Into notch size (24dp or notch width)
hasAvatar: Boolean 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 { ): 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 { val radius: Float = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { // Pull-down expansion (like Telegram isPulledDown)
// Expanding to full screen hasAvatar && expansionProgress > 0f -> {
val expandedRadius = screenWidthPx / 2f // Telegram: avatarScale = lerp(96/42, 138/42, min(1, expandProgress*3)) / 100f * 42f
lerpFloat(avatarSizeExpandedPx / 2f, expandedRadius, sharpExpansion) val expandScale = lerpFloat(
avatarSizeExpandedPx / 2f, // Normal radius
screenWidthPx / 2f, // Full screen width / 2
expansionProgress
)
expandScale
} }
// Collapsing into notch
collapseProgress > 0f -> { collapseProgress > 0f -> {
// Collapsing - shrink avatar // Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f) // vr = baseWidth * avatarScale * 0.5
lerpFloat(avatarSizeExpandedPx / 2f, avatarSizeMinPx / 2f, shrinkProgress) 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 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 val centerX = screenWidthPx / 2f
// Calculate Y position // ═══════════════════════════════════════════════════════════════
val defaultCenterY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
val targetY = statusBarHeightPx / 2f // Target: center of status bar (camera area) // 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 { val centerY: Float = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { // Pull-down expansion
lerpFloat(defaultCenterY, headerHeightPx / 2f, sharpExpansion) hasAvatar && expansionProgress > 0f -> {
lerpFloat(startY, headerHeightPx / 2f, expansionProgress)
} }
// Collapsing - animate Y towards notch
else -> { else -> {
lerpFloat(defaultCenterY, targetY, collapseProgress) lerpFloat(endY, startY, diff)
} }
} }
// Opacity // ═══════════════════════════════════════════════════════════════
val opacity: Float = when { // CORNER RADIUS - Shape transition
collapseProgress < ProfileMetaballConstants.MERGE_START_PROGRESS -> 1f // Telegram: аватарка остаётся КРУГЛОЙ пока тянешь!
collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f // Квадратной становится только при полном раскрытии (isPulledDown)
else -> { // Переход круг→квадрат начинается при expansionProgress > 0.8
val mergeProgress = (collapseProgress - ProfileMetaballConstants.MERGE_START_PROGRESS) / // ═══════════════════════════════════════════════════════════════
(ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS - ProfileMetaballConstants.MERGE_START_PROGRESS) val cornerRadius: Float = when {
(1f - mergeProgress).coerceIn(0f, 1f) // 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 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 { private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
@@ -226,8 +372,15 @@ fun ProfileMetaballOverlay(
} }
} }
// Avatar state // Telegram thresholds in pixels
val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) { 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 { derivedStateOf {
computeAvatarState( computeAvatarState(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
@@ -237,7 +390,15 @@ fun ProfileMetaballOverlay(
headerHeightPx = headerHeightPx, headerHeightPx = headerHeightPx,
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, 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,95 +416,101 @@ fun ProfileMetaballOverlay(
val distance = avatarState.centerY - notchCenterY val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx val maxDist = avatarSizeExpandedPx
val c = (distance / maxDist).coerceIn(-1f, 1f) 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) // Like Telegram: when NOT near, reduce v
// Avatar radius thresholds (in px) // float near = isNear ? 1f : 1f - (vr - dp(32)) / dp(40 - 32);
val dp40 = with(density) { 40.dp.toPx() } // v = Math.min(lerp(0f, 0.2f, near), v);
val dp32 = with(density) { 32.dp.toPx() } val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
// Should we draw the metaball effect? (when avatar is small enough) val v = if (!avatarState.isNear) {
val isDrawing = avatarState.radius <= dp40 min(lerpFloat(0f, 0.2f, near), baseV)
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)
} else { } 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()) { Box(modifier = modifier.fillMaxSize()) {
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only) // LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
Canvas( // HIDDEN when expanded - only show avatar content
modifier = Modifier if (showMetaballLayer) {
.fillMaxSize() Canvas(
.graphicsLayer { modifier = Modifier
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF) .fillMaxSize()
// IMPORTANT: First blur, THEN threshold shader .graphicsLayer {
// createChainEffect(outer, inner) - inner is applied first metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
val blurEffect = RenderEffect.createBlurEffect( // IMPORTANT: First blur, THEN threshold shader
ProfileMetaballConstants.BLUR_RADIUS, // createChainEffect(outer, inner) - inner is applied first
ProfileMetaballConstants.BLUR_RADIUS, val blurEffect = RenderEffect.createBlurEffect(
Shader.TileMode.DECAL ProfileMetaballConstants.BLUR_RADIUS,
) ProfileMetaballConstants.BLUR_RADIUS,
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable") Shader.TileMode.DECAL
// Chain: blur first (inner), then threshold (outer)
renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
.asComposeRenderEffect()
}
) {
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.FILL
}
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// Draw notch/camera circle (small circle at top)
if (showConnector) {
if (notchInfo != null && notchInfo.isLikelyCircle) {
// Draw circle at actual notch position
nativeCanvas.drawCircle(
notchCenterX,
notchCenterY,
notchRadiusPx,
paint
) )
} else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable")
// Draw actual notch path // Chain: blur first (inner), then threshold (outer)
nativeCanvas.drawPath(notchInfo.path, paint) renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
} else if (notchInfo != null) { .asComposeRenderEffect()
// Draw rounded rect for non-accurate notch }
val bounds = notchInfo.bounds ) {
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
nativeCanvas.drawRoundRect(bounds, rad, rad, paint) color = android.graphics.Color.BLACK
} else { style = android.graphics.Paint.Style.FILL
// No notch - draw small circle at status bar center }
nativeCanvas.drawCircle(
screenWidthPx / 2f, drawIntoCanvas { canvas ->
statusBarHeightPx / 2f, val nativeCanvas = canvas.nativeCanvas
notchRadiusPx,
// Draw notch/camera circle (small circle at top)
if (showConnector) {
if (notchInfo != null && notchInfo.isLikelyCircle) {
// Draw circle at actual notch position
nativeCanvas.drawCircle(
notchCenterX,
notchCenterY,
notchRadiusPx,
paint
)
} else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
// Draw actual notch path
nativeCanvas.drawPath(notchInfo.path, paint)
} else if (notchInfo != null) {
// Draw rounded rect for non-accurate notch
val bounds = notchInfo.bounds
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
} else {
// No notch - draw small circle at status bar center
nativeCanvas.drawCircle(
screenWidthPx / 2f,
statusBarHeightPx / 2f,
notchRadiusPx,
paint paint
) )
} }
} }
// Draw avatar circle // Draw avatar shape - circle or rounded rect depending on state
if (avatarState.showBlob) { if (avatarState.showBlob) {
nativeCanvas.drawCircle( val cx = avatarState.centerX
avatarState.centerX, val cy = avatarState.centerY
avatarState.centerY, val r = avatarState.radius
avatarState.radius, val cornerR = avatarState.cornerRadius
paint
) // 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 // 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) { if (avatarState.showBlob) {
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() } // При раскрытии - занимаем всю ширину экрана и всю высоту header'а
val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() } val isExpanded = expansionProgress > 0f
val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() } 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( Box(
modifier = Modifier modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY) .offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp) .width(avatarWidthDp)
.height(avatarSizeDp) .height(avatarHeightDp)
.clip(RoundedCornerShape(avatarSizeDp / 2)) // Use dynamic corner radius - circle → rounded rect transition
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer { .graphicsLayer {
alpha = avatarState.opacity alpha = avatarState.opacity
// Apply blur to avatar when close to notch (like Telegram) // Apply blur to avatar when near notch (like Telegram)
if (avatarBlurRadius > 0.5f) { // Telegram: blurRadius = 2 + (1 - fraction) * 20
if (avatarState.blurRadius > 0.5f) {
renderEffect = RenderEffect.createBlurEffect( renderEffect = RenderEffect.createBlurEffect(
avatarBlurRadius, avatarState.blurRadius,
avatarBlurRadius, avatarState.blurRadius,
Shader.TileMode.DECAL Shader.TileMode.DECAL
).asComposeRenderEffect() ).asComposeRenderEffect()
} }
@@ -392,6 +576,7 @@ fun ProfileMetaballOverlay(
/** /**
* Compat version for older Android - simple animation without metaball effect * Compat version for older Android - simple animation without metaball effect
* Still includes shape transition (circle → rounded rect) but no blur/gooey effects
*/ */
@Composable @Composable
fun ProfileMetaballOverlayCompat( fun ProfileMetaballOverlayCompat(
@@ -414,6 +599,17 @@ fun ProfileMetaballOverlayCompat(
val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() }
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.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) { val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) {
derivedStateOf { derivedStateOf {
computeAvatarState( computeAvatarState(
@@ -424,23 +620,43 @@ fun ProfileMetaballOverlayCompat(
headerHeightPx = headerHeightPx, headerHeightPx = headerHeightPx,
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, 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()) { Box(modifier = modifier.fillMaxSize()) {
if (avatarState.showBlob) { if (avatarState.showBlob) {
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() } // При раскрытии - занимаем всю ширину экрана и всю высоту header'а
val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() } val isExpanded = expansionProgress > 0f
val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() } 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( Box(
modifier = Modifier modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY) .offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp) .width(avatarWidthDp)
.height(avatarSizeDp) .height(avatarHeightDp)
.clip(RoundedCornerShape(avatarSizeDp / 2)) .clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer { .graphicsLayer {
alpha = avatarState.opacity alpha = avatarState.opacity
}, },

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import android.util.Log
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
@@ -290,12 +291,13 @@ fun ProfileScreen(
val maxScrollOffset = expandedHeightPx - collapsedHeightPx val maxScrollOffset = expandedHeightPx - collapsedHeightPx
// Track scroll offset for collapsing (скролл вверх = collapse) // Track scroll offset for collapsing (скролл вверх = collapse)
// Telegram: extraHeight - напрямую от позиции первого элемента
var scrollOffset by remember { mutableFloatStateOf(0f) } var scrollOffset by remember { mutableFloatStateOf(0f) }
// Calculate collapse progress (0 = expanded, 1 = collapsed) // Calculate collapse progress (0 = expanded, 1 = collapsed)
val collapseProgress by remember { // Telegram: diff = (extraHeight - actionsHeight) / headerOnlyExtraHeight
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) } // Напрямую без derivedStateOf для плавности
} val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f)
// Dynamic header height based on scroll // Dynamic header height based on scroll
val headerHeight = val headerHeight =
@@ -304,40 +306,46 @@ fun ProfileScreen(
} }
// Track overscroll offset for avatar expansion (скролл вниз при достижении верха) // Track overscroll offset for avatar expansion (скролл вниз при достижении верха)
// Telegram: когда extraHeight > headerExtraHeight - это isPulledDown
var overscrollOffset by remember { mutableFloatStateOf(0f) } var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 200.dp.toPx() } 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 // Track if user is currently dragging
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
// Track if fully expanded (snapped to full)
var isFullyExpanded by remember { mutableStateOf(false) }
// Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат) // Smooth snap animation - только когда отпустили палец
val snapTarget by remember { // Telegram использует CubicBezierInterpolator.EASE_BOTH для snap
derivedStateOf { val targetOverscroll = when {
if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll isDragging -> overscrollOffset // Во время drag - напрямую
else if (!isDragging) 0f else overscrollOffset isFullyExpanded -> maxOverscroll // Зафиксировано в раскрытом
} overscrollOffset > snapThreshold -> maxOverscroll // Snap к раскрытому
else -> 0f // Snap к закрытому
} }
// Быстрая snap-анимация без застревания val animatedOverscroll by animateFloatAsState(
val animatedOverscroll by targetValue = targetOverscroll,
animateFloatAsState( animationSpec = tween(
targetValue = snapTarget, durationMillis = if (isDragging) 0 else 250, // Мгновенно при drag, плавно при snap
animationSpec = easing = FastOutSlowInEasing // Как Telegram's EASE_BOTH
spring( ),
dampingRatio = Spring.DampingRatioMediumBouncy, // Меньше пружинистости label = "overscroll"
stiffness = 4000f // Очень быстрый snap )
),
label = "overscroll"
)
// Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт // Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт
val expansionProgress by remember { // Telegram: expandProgress = (extraHeight - headerExtraHeight) / (listWidth - actionBarHeight - headerOnlyExtraHeight)
derivedStateOf { val expansionProgress = when {
if (collapseProgress > 0.1f) 0f // Не расширяем если header коллапсирован collapseProgress > 0.1f -> 0f // Не расширяем если header коллапсирован
else (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) 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 // Nested scroll connection для collapsing + overscroll
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
@@ -353,6 +361,10 @@ fun ProfileScreen(
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
val consumed = overscrollOffset - newOffset val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset overscrollOffset = newOffset
// Если вышли из fully expanded - сбросить флаг
if (overscrollOffset < maxOverscroll * 0.9f) {
isFullyExpanded = false
}
return Offset(0f, -consumed) return Offset(0f, -consumed)
} }
// Затем коллапсируем header // Затем коллапсируем header
@@ -382,7 +394,8 @@ fun ProfileScreen(
): Offset { ): Offset {
// Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll // Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll
if (available.y > 0 && scrollOffset == 0f) { 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 val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll) overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
return Offset(0f, available.y) return Offset(0f, available.y)
@@ -392,6 +405,10 @@ fun ProfileScreen(
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false isDragging = false
// Если перешли порог - зафиксировать как fully expanded
if (overscrollOffset > snapThreshold) {
isFullyExpanded = true
}
return Velocity.Zero return Velocity.Zero
} }
} }