feat: implement metaball effect for avatar merging in ProfileScreen

This commit is contained in:
k1ngsterr1
2026-02-01 00:06:56 +05:00
parent 196cc9c4a2
commit a55a5b4668
3 changed files with 489 additions and 100 deletions

View File

@@ -0,0 +1,130 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
import org.intellij.lang.annotations.Language
/**
* AGSL Shader for metaball effect
*
* This shader creates the "liquid merge" effect by:
* 1. Taking already-blurred content as input
* 2. Applying alpha threshold cutoff (alpha > cutoff = opaque, else transparent)
*
* When two blurred shapes overlap, their alpha values combine,
* exceeding the threshold and creating a merged appearance.
*/
@Language("AGSL")
const val MetaballShaderSource = """
uniform shader composable;
uniform float cutoff;
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
float alpha = color.a;
// Hard threshold: if alpha > cutoff, make fully opaque, else transparent
if (alpha > cutoff) {
alpha = 1.0;
} else {
alpha = 0.0;
}
// Return color with modified alpha
return half4(color.r, color.g, color.b, alpha);
}
"""
/**
* Container that applies the metaball shader effect to its children.
*
* Children should use [customBlur] modifier to be part of the metaball effect.
* When blurred shapes overlap, they appear to "merge" like liquid blobs.
*
* @param modifier Modifier for the container
* @param cutoff Alpha threshold (0-1). Higher = harder edges. Default 0.5
* @param content Composable content (should contain MetaEntity elements)
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun MetaContainer(
modifier: Modifier = Modifier,
cutoff: Float = 0.5f,
content: @Composable BoxScope.() -> Unit,
) {
val metaShader = remember { RuntimeShader(MetaballShaderSource) }
Box(
modifier = modifier.graphicsLayer {
metaShader.setFloatUniform("cutoff", cutoff)
renderEffect = RenderEffect.createRuntimeShaderEffect(
metaShader, "composable"
).asComposeRenderEffect()
},
content = content,
)
}
/**
* Modifier that applies GPU-accelerated blur effect.
*
* Use this on shapes inside [MetaContainer] to create the metaball effect.
* The blur radius determines how "soft" the edges are before threshold is applied.
*
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
*/
@RequiresApi(Build.VERSION_CODES.S)
fun Modifier.customBlur(blur: Float) = this.then(
graphicsLayer {
if (blur > 0f) {
renderEffect = RenderEffect
.createBlurEffect(
blur,
blur,
Shader.TileMode.DECAL,
)
.asComposeRenderEffect()
}
}
)
/**
* A composable that wraps content with metaball-capable blur.
*
* This creates a layered effect:
* - metaContent: The blurred shape that participates in metaball merging
* - content: The actual visible content (not blurred)
*
* @param modifier Modifier for the entity
* @param blur Blur radius for the metaball effect
* @param metaContent The shape that will be blurred and merged (usually a solid color Box)
* @param content The visible content rendered on top
*/
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun MetaEntity(
modifier: Modifier = Modifier,
blur: Float = 30f,
metaContent: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(modifier = modifier) {
// Blurred layer for metaball effect
Box(
modifier = Modifier.customBlur(blur),
content = metaContent,
)
// Visible content on top
content()
}
}

View File

@@ -0,0 +1,327 @@
package com.rosetta.messenger.ui.components.metaball
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import kotlin.math.sqrt
/**
* Constants for the Profile Metaball Animation
* Avatar merges with actual device Dynamic Island / display cutout
*/
object ProfileMetaballConstants {
// Avatar dimensions (must match ProfileScreen constants)
val AVATAR_SIZE_EXPANDED = 120.dp
val AVATAR_SIZE_MIN = 28.dp // Minimum size before disappearing into cutout
// Animation thresholds
const val MERGE_START_PROGRESS = 0.6f // When avatar starts fading
const val MERGE_COMPLETE_PROGRESS = 0.95f // When avatar fully disappears
// Blur settings for smooth edges
const val BLUR_RADIUS = 20f
const val CUTOFF = 0.5f
}
/**
* Computed state for the avatar blob position and size
*/
private data class AvatarBlobState(
val x: Dp,
val y: Dp,
val width: Dp,
val height: Dp,
val cornerRadius: Dp,
val opacity: Float,
val showBlob: Boolean
)
/**
* Compute avatar blob state based on progress
*/
private fun computeAvatarBlobState(
collapseProgress: Float,
expansionProgress: Float,
screenWidth: Dp,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean
): AvatarBlobState {
val sharpExpansion = sqrt(expansionProgress.toDouble()).toFloat()
// Avatar zone
val avatarZoneHeight = headerHeight
// Target position - center top of screen (where Dynamic Island / notch usually is)
val targetY = statusBarHeight / 2
// Calculate size
val width: Dp
val height: Dp
when {
// Overscroll expansion (only with avatar)
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
width = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, screenWidth, sharpExpansion)
height = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, avatarZoneHeight, sharpExpansion)
}
// Collapse: shrink to min size
collapseProgress > 0f -> {
val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f)
val shrunkSize = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, ProfileMetaballConstants.AVATAR_SIZE_MIN, shrinkProgress)
width = shrunkSize
height = shrunkSize
}
else -> {
width = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
height = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
}
}
val size = if (width < height) width else height
// Calculate position
val x = (screenWidth - width) / 2
val defaultCenterY = statusBarHeight + (avatarZoneHeight - statusBarHeight - height) / 2 + 20.dp
val topY = 0.dp
val y = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
lerp(defaultCenterY, topY, sharpExpansion)
}
else -> {
// Move towards the top (Dynamic Island / notch area)
lerp(defaultCenterY, targetY - size / 2, collapseProgress)
}
}
// Corner radius
val cornerRadius = when {
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
lerp(size / 2, 0.dp, sharpExpansion)
}
else -> size / 2
}
// Opacity (fade out during merge)
val opacity = 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)
}
}
val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && size > 1.dp
return AvatarBlobState(
x = x,
y = y,
width = width,
height = height,
cornerRadius = cornerRadius,
opacity = opacity,
showBlob = showBlob
)
}
/**
* Profile Metaball Overlay - Creates the liquid merge effect between avatar and island
*
* This composable renders an avatar blob that:
* - Expands to full screen on overscroll (pull down)
* - Shrinks and moves up towards the real Dynamic Island / notch on scroll up
* - Fades out as it reaches the top of the screen
*
* NO fake Dynamic Island is drawn - we just animate towards the real device cutout.
*
* @param collapseProgress Scroll collapse progress (0 = expanded, 1 = collapsed)
* @param expansionProgress Overscroll expansion (0 = normal, 1 = full square)
* @param statusBarHeight Height of the status bar
* @param headerHeight Total height of the header
* @param hasAvatar Whether user has an avatar image
* @param avatarColor Fallback color for avatar blob
* @param avatarContent The actual avatar content to display inside the blob
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun ProfileMetaballOverlay(
collapseProgress: Float,
expansionProgress: Float,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean,
@Suppress("UNUSED_PARAMETER") avatarColor: Color,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) {
derivedStateOf {
computeAvatarBlobState(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
screenWidth = screenWidth,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar
)
}
}
// MetaContainer applies blur + threshold for smooth edges
MetaContainer(
modifier = modifier.fillMaxSize(),
cutoff = ProfileMetaballConstants.CUTOFF
) {
// Avatar blob only - NO fake island
if (avatarState.showBlob) {
MetaEntity(
modifier = Modifier.offset(x = avatarState.x, y = avatarState.y),
blur = ProfileMetaballConstants.BLUR_RADIUS,
metaContent = {
// The blob shape for metaball effect
Box(
modifier = Modifier
.width(avatarState.width)
.height(avatarState.height)
.background(
color = Color.Black,
shape = RoundedCornerShape(avatarState.cornerRadius)
)
)
},
content = {
// Actual avatar content with fade
Box(
modifier = Modifier
.width(avatarState.width)
.height(avatarState.height)
.clip(RoundedCornerShape(avatarState.cornerRadius))
.graphicsLayer {
alpha = avatarState.opacity
},
contentAlignment = Alignment.Center,
content = avatarContent
)
}
)
}
}
}
/**
* Simplified version without MetaContainer for devices < Android 13
* Falls back to simple avatar animation without the merge effect
*/
@Composable
fun ProfileMetaballOverlayCompat(
collapseProgress: Float,
expansionProgress: Float,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean,
avatarColor: Color,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) {
derivedStateOf {
computeAvatarBlobState(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
screenWidth = screenWidth,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar
)
}
}
Box(modifier = modifier.fillMaxSize()) {
// Simple avatar (no metaball effect, but keeps the animation)
if (avatarState.showBlob) {
Box(
modifier = Modifier
.offset(x = avatarState.x, y = avatarState.y)
.width(avatarState.width)
.height(avatarState.height)
.clip(RoundedCornerShape(avatarState.cornerRadius))
.background(avatarColor)
.graphicsLayer {
alpha = avatarState.opacity
},
contentAlignment = Alignment.Center,
content = avatarContent
)
}
}
}
/**
* Wrapper that automatically chooses the right implementation based on Android version
*/
@Composable
fun ProfileMetaballEffect(
collapseProgress: Float,
expansionProgress: Float,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean,
avatarColor: Color,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ProfileMetaballOverlay(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
} else {
ProfileMetaballOverlayCompat(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
}
}

View File

@@ -54,6 +54,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import compose.icons.TablerIcons
@@ -717,9 +718,8 @@ private fun CollapsingProfileHeader(
hasAvatar: Boolean,
avatarRepository: AvatarRepository?
) {
@Suppress("UNUSED_VARIABLE")
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
// Get actual status bar height
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@@ -793,69 +793,10 @@ private fun CollapsingProfileHeader(
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - По умолчанию КРУГЛАЯ, при overscroll расширяется до прямоугольника
// При collapse - уменьшается и уходит вверх
// ТОЛЬКО ЕСЛИ ЕСТЬ АВАТАРКА! Без аватарки всегда круг
// 👤 AVATAR - Размер шрифта для placeholder (без аватарки)
// Основная логика теперь в ProfileMetaballEffect
// ═══════════════════════════════════════════════════════════
val circleSize = AVATAR_SIZE_EXPANDED
// Зона аватарки = ВСЯ высота header включая статус бар
val avatarZoneHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
// Резкая анимация - используем easeOut (быстро в начале, медленно в конце)
// sqrt делает анимацию более резкой/быстрой
val sharpExpansion = kotlin.math.sqrt(expansionProgress.toDouble()).toFloat()
// При overscroll расширяем до прямоугольника на всю зону (только если не collapsed И есть
// аватарка)
val avatarWidth: Dp
val avatarHeight: Dp
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
// Overscroll: круг -> прямоугольник на всю зону ВКЛЮЧАЯ статус бар (ТОЛЬКО С АВАТАРКОЙ)
// Используем sharpExpansion для резкой анимации
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, sharpExpansion)
avatarHeight = androidx.compose.ui.unit.lerp(circleSize, avatarZoneHeight, sharpExpansion)
} else {
// Collapse: сразу начинаем уменьшаться от круга до 0
val collapsedSize = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseProgress)
avatarWidth = collapsedSize
avatarHeight = collapsedSize
}
val avatarSize = if (avatarWidth < avatarHeight) avatarWidth else avatarHeight
// Позиция X: всегда по центру
val avatarX = (screenWidthDp - avatarWidth) / 2
// Позиция Y
val availableHeight = avatarZoneHeight - statusBarHeight
val defaultCenterY = statusBarHeight + (availableHeight - avatarHeight) / 2
val topAvatarY = 0.dp // От самого верха экрана при полном expansion
val avatarY =
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
// При overscroll прижимаемся к самому верху (ТОЛЬКО С АВАТАРКОЙ)
// Используем sharpExpansion для резкой анимации
androidx.compose.ui.unit.lerp(defaultCenterY, topAvatarY, sharpExpansion)
} else {
// Collapse: сразу начинаем уходить вверх
androidx.compose.ui.unit.lerp(
defaultCenterY,
statusBarHeight - 80.dp,
collapseProgress
)
}
// Закругление: круг по умолчанию, при overscroll плавно становится квадратом (ТОЛЬКО С
// АВАТАРКОЙ)
val cornerRadius =
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
// Overscroll: круг -> квадрат без скругления (резкая анимация)
androidx.compose.ui.unit.lerp(avatarSize / 2, 0.dp, sharpExpansion)
} else {
// Всегда круг
avatarSize / 2
}
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
val hapticFeedback = LocalHapticFeedback.current
@@ -870,8 +811,6 @@ private fun CollapsingProfileHeader(
}
}
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
// ═══════════════════════════════════════════════════════════
// 📝 TEXT - внизу header зоны, внутри блока
// ═══════════════════════════════════════════════════════════
@@ -898,45 +837,38 @@ private fun CollapsingProfileHeader(
)
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - Круг по умолчанию, квадрат при overscroll (ТОЛЬКО С АВАТАРКОЙ)
// Без аватарки - всегда круглый placeholder как в sidebar
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation on scroll
// При скролле вверх аватарка "сливается" с Dynamic Island
// Используем metaball эффект для плавного слияния форм
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
if (hasAvatar) {
// С аватаркой - расширяется до квадрата
Box(
modifier =
Modifier.offset(x = avatarX, y = avatarY)
.size(width = avatarWidth, height = avatarHeight)
.clip(RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center
) {
if (avatarRepository != null) {
FullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
}
}
ProfileMetaballEffect(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColors.backgroundColor,
modifier = Modifier.fillMaxSize()
) {
// Содержимое аватара
if (hasAvatar && avatarRepository != null) {
FullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
// Без аватарки - ВСЕГДА круглый placeholder как в sidebar
// Placeholder без аватарки
Box(
modifier =
Modifier.offset(x = avatarX, y = avatarY)
.size(avatarSize)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (avatarFontSize > 1.sp) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
}