feat: implement metaball effect for avatar merging in ProfileScreen

This commit is contained in:
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
)
}
}