From a55a5b466800076972c3dbb61943123c14e5ce1f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 1 Feb 2026 00:06:56 +0500 Subject: [PATCH] feat: implement metaball effect for avatar merging in ProfileScreen --- .../ui/components/metaball/MetaballEffect.kt | 130 +++++++ .../metaball/ProfileMetaballOverlay.kt | 327 ++++++++++++++++++ .../messenger/ui/settings/ProfileScreen.kt | 132 ++----- 3 files changed, 489 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt new file mode 100644 index 0000000..ead542a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt @@ -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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt new file mode 100644 index 0000000..4008287 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index ef78cb2..a166d6a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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 + ) } } }