feat: implement Telegram-style avatar expansion with smooth scaling and snap animation in ProfileScreen
This commit is contained in:
@@ -528,48 +528,94 @@ fun ProfileMetaballOverlay(
|
||||
}
|
||||
} // END if (showMetaballLayer)
|
||||
|
||||
// LAYER 2: Actual avatar content - with blur and shape transition like Telegram
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LAYER 2: Avatar content - TELEGRAM STYLE EXPANSION
|
||||
// При expansion: плавно растёт через SCALE (не размер!)
|
||||
// Это даёт smooth анимацию как в Telegram
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if (avatarState.showBlob) {
|
||||
// При раскрытии - занимаем всю ширину экрана и всю высоту 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 baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
||||
val baseSizePx = with(density) { baseSizeDp.toPx() }
|
||||
|
||||
// Corner radius from state - transitions from circle to rounded rect
|
||||
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
|
||||
// Позиция центра
|
||||
val avatarCenterX = avatarState.centerX
|
||||
val avatarCenterY = avatarState.centerY
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||
.width(avatarWidthDp)
|
||||
.height(avatarHeightDp)
|
||||
// Use dynamic corner radius - circle → rounded rect transition
|
||||
.clip(RoundedCornerShape(cornerRadiusDp))
|
||||
.graphicsLayer {
|
||||
alpha = avatarState.opacity
|
||||
// Apply blur to avatar when near notch (like Telegram)
|
||||
// Telegram: blurRadius = 2 + (1 - fraction) * 20
|
||||
if (avatarState.blurRadius > 0.5f) {
|
||||
renderEffect = RenderEffect.createBlurEffect(
|
||||
avatarState.blurRadius,
|
||||
avatarState.blurRadius,
|
||||
Shader.TileMode.DECAL
|
||||
).asComposeRenderEffect()
|
||||
// При expansion > 0: используем SCALE для плавного роста
|
||||
// При collapse: используем обычный размер
|
||||
val isExpanding = expansionProgress > 0f
|
||||
|
||||
if (isExpanding) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// EXPANSION MODE - плавный рост через scale
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val targetWidth = screenWidthPx
|
||||
val targetHeight = headerHeightPx
|
||||
|
||||
// Scale от базового размера до целевого
|
||||
val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress)
|
||||
val scaleY = lerpFloat(1f, targetHeight / baseSizePx, expansionProgress)
|
||||
|
||||
// Центр смещается к центру экрана/header
|
||||
val targetCenterX = screenWidthPx / 2f
|
||||
val targetCenterY = headerHeightPx / 2f
|
||||
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
|
||||
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
|
||||
|
||||
// Offset для позиционирования (учитывая что scale от центра)
|
||||
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
|
||||
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
|
||||
|
||||
// Corner radius плавно уменьшается к 0 (квадрат)
|
||||
// Но только после 70% expansion для эффекта как в Telegram
|
||||
val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f)
|
||||
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress)
|
||||
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = offsetX, y = offsetY)
|
||||
.width(baseSizeDp)
|
||||
.height(baseSizeDp)
|
||||
.graphicsLayer {
|
||||
this.scaleX = scaleX
|
||||
this.scaleY = scaleY
|
||||
alpha = avatarState.opacity
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
.clip(RoundedCornerShape(cornerRadiusDp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
} else {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// COLLAPSE/NORMAL MODE - обычный размер для капли
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
|
||||
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
|
||||
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
|
||||
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||
.width(avatarSizeDp)
|
||||
.height(avatarSizeDp)
|
||||
.clip(RoundedCornerShape(cornerRadiusDp))
|
||||
.graphicsLayer {
|
||||
alpha = avatarState.opacity
|
||||
if (avatarState.blurRadius > 0.5f) {
|
||||
renderEffect = RenderEffect.createBlurEffect(
|
||||
avatarState.blurRadius,
|
||||
avatarState.blurRadius,
|
||||
Shader.TileMode.DECAL
|
||||
).asComposeRenderEffect()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -635,34 +681,62 @@ fun ProfileMetaballOverlayCompat(
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (avatarState.showBlob) {
|
||||
// При раскрытии - занимаем всю ширину экрана и всю высоту 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() }
|
||||
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
||||
val baseSizePx = with(density) { baseSizeDp.toPx() }
|
||||
val avatarCenterX = avatarState.centerX
|
||||
val avatarCenterY = avatarState.centerY
|
||||
val isExpanding = expansionProgress > 0f
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||
.width(avatarWidthDp)
|
||||
.height(avatarHeightDp)
|
||||
.clip(RoundedCornerShape(cornerRadiusDp))
|
||||
.graphicsLayer {
|
||||
alpha = avatarState.opacity
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
if (isExpanding) {
|
||||
// EXPANSION MODE - scale animation
|
||||
val targetWidth = screenWidthPx
|
||||
val targetHeight = headerHeightPx
|
||||
val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress)
|
||||
val scaleY = lerpFloat(1f, targetHeight / baseSizePx, expansionProgress)
|
||||
val targetCenterX = screenWidthPx / 2f
|
||||
val targetCenterY = headerHeightPx / 2f
|
||||
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
|
||||
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
|
||||
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
|
||||
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
|
||||
val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f)
|
||||
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress)
|
||||
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = offsetX, y = offsetY)
|
||||
.width(baseSizeDp)
|
||||
.height(baseSizeDp)
|
||||
.graphicsLayer {
|
||||
this.scaleX = scaleX
|
||||
this.scaleY = scaleY
|
||||
alpha = avatarState.opacity
|
||||
}
|
||||
.clip(RoundedCornerShape(cornerRadiusDp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
} else {
|
||||
// COLLAPSE/NORMAL MODE
|
||||
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
|
||||
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
|
||||
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
|
||||
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||
.width(avatarSizeDp)
|
||||
.height(avatarSizeDp)
|
||||
.clip(RoundedCornerShape(cornerRadiusDp))
|
||||
.graphicsLayer {
|
||||
alpha = avatarState.opacity
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
content = avatarContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user