feat: implement avatar expansion and collapse logic with smooth snapping in ProfileScreen
This commit is contained in:
@@ -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,95 +416,101 @@ 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)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
|
||||
// IMPORTANT: First blur, THEN threshold shader
|
||||
// createChainEffect(outer, inner) - inner is applied first
|
||||
val blurEffect = RenderEffect.createBlurEffect(
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
Shader.TileMode.DECAL
|
||||
)
|
||||
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable")
|
||||
// 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
|
||||
// HIDDEN when expanded - only show avatar content
|
||||
if (showMetaballLayer) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
|
||||
// IMPORTANT: First blur, THEN threshold shader
|
||||
// createChainEffect(outer, inner) - inner is applied first
|
||||
val blurEffect = RenderEffect.createBlurEffect(
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
Shader.TileMode.DECAL
|
||||
)
|
||||
} 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,
|
||||
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable")
|
||||
// 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) {
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
|
||||
@@ -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,40 +306,46 @@ 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) }
|
||||
|
||||
// Track if fully expanded (snapped to full)
|
||||
var isFullyExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат)
|
||||
val snapTarget by remember {
|
||||
derivedStateOf {
|
||||
if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll
|
||||
else if (!isDragging) 0f else overscrollOffset
|
||||
}
|
||||
// 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
|
||||
),
|
||||
label = "overscroll"
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user