From a61887cc5b5a670106797569c0b774f8d17e1fc6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 1 Feb 2026 03:36:49 +0500 Subject: [PATCH] feat: implement metaball path for avatar merging effect in ProfileMetaballOverlay --- .../ui/components/metaball/MetaballPath.kt | 137 +++++ .../ui/components/metaball/NotchInfoUtils.kt | 131 +++++ .../metaball/ProfileMetaballOverlay.kt | 529 +++++++++++------- 3 files changed, 588 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballPath.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/NotchInfoUtils.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballPath.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballPath.kt new file mode 100644 index 0000000..2f645a8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballPath.kt @@ -0,0 +1,137 @@ +package com.rosetta.messenger.ui.components.metaball + +import android.graphics.Path +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Point helper class for metaball calculations + */ +data class Point(var x: Float = 0f, var y: Float = 0f) + +/** + * Creates a metaball path between two circles - exact port from Telegram's ProfileMetaballView.java + * + * This creates the beautiful "liquid droplet" effect when two circles are close together. + * The path uses cubic Bezier curves to create smooth organic connections. + * + * @param c1 Center of first circle (top/notch) + * @param c2 Center of second circle (avatar) + * @param radius1 Radius of first circle + * @param radius2 Radius of second circle + * @param v Connector "thickness" parameter (0-1, higher = thicker connection) + * @param path Path object to write to (will be rewound) + * @return true if path was created, false if circles are too far apart + */ +fun createMetaballPath( + c1: Point, + c2: Point, + radius1: Float, + radius2: Float, + v: Float, + path: Path +): Boolean { + val HALF_PI = (Math.PI / 2).toFloat() + val HANDLE_SIZE = 20.0f + + // Helper points for path calculation + val p1 = Point() + val p2 = Point() + val p3 = Point() + val p4 = Point() + val h1 = Point() + val h2 = Point() + val h3 = Point() + val h4 = Point() + val a1 = Point() + val a2 = Point() + + // Distance between centers + val d = dist(c1, c2) + val maxDist = radius1 + radius2 * 2.25f + var u1 = 0f + var u2 = 0f + + // Check if circles can be connected + if (radius1 == 0f || radius2 == 0f || d > maxDist || d <= abs(radius1 - radius2)) { + return false + } + + // Calculate overlap angles + if (d < radius1 + radius2) { + u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)) + u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)) + } + + val angleBetweenCenters = angle(c2, c1) + val maxSpread = acos((radius1 - radius2) / d) + + // Calculate connection angles + val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v + val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v + val angle3 = angleBetweenCenters + Math.PI.toFloat() - u2 - (Math.PI.toFloat() - u2 - maxSpread) * v + val angle4 = angleBetweenCenters - Math.PI.toFloat() + u2 + (Math.PI.toFloat() - u2 - maxSpread) * v + + // Get points on circle edges + getVector(c1, angle1, radius1, p1) + getVector(c1, angle2, radius1, p2) + getVector(c2, angle3, radius2, p3) + getVector(c2, angle4, radius2, p4) + + // Calculate handle lengths for Bezier curves + val totalRadius = radius1 + radius2 + val d2Base = minOf(v * HANDLE_SIZE, dist(p1, p3) / totalRadius) + val d2 = d2Base * minOf(1f, d * 2 / totalRadius) + + val r1 = radius1 * d2 + val r2 = radius2 * d2 + + // Get Bezier handle points + getVector(p1, angle1 - HALF_PI, r1, h1) + getVector(p2, angle2 + HALF_PI, r1, h2) + getVector(p3, angle3 + HALF_PI, r2, h3) + getVector(p4, angle4 - HALF_PI, r2, h4) + + // Build the path + path.rewind() + path.moveTo(p1.x, p1.y) + path.cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y) + + // Smooth curve around bottom circle + getVector(p3, angle3 + HALF_PI, r2 * 0.55f, a1) + getVector(p4, angle4 - HALF_PI, r2 * 0.55f, a2) + path.cubicTo(a1.x, a1.y, a2.x, a2.y, p4.x, p4.y) + + path.cubicTo(h4.x, h4.y, h2.x, h2.y, p2.x, p2.y) + path.close() + + return true +} + +/** + * Distance between two points + */ +private fun dist(a: Point, b: Point): Float { + val dx = a.x - b.x + val dy = a.y - b.y + return sqrt(dx * dx + dy * dy) +} + +/** + * Angle from point b to point a + */ +private fun angle(a: Point, b: Point): Float { + return atan2(a.y - b.y, a.x - b.x) +} + +/** + * Get point at angle and radius from center + */ +private fun getVector(center: Point, angle: Float, radius: Float, out: Point) { + out.x = center.x + radius * cos(angle) + out.y = center.y + radius * sin(angle) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/NotchInfoUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/NotchInfoUtils.kt new file mode 100644 index 0000000..88f3789 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/NotchInfoUtils.kt @@ -0,0 +1,131 @@ +package com.rosetta.messenger.ui.components.metaball + +import android.content.Context +import android.graphics.Path +import android.graphics.RectF +import android.os.Build +import android.view.Gravity +import androidx.core.graphics.PathParser + +/** + * Utility to get notch/camera cutout information - ported from Telegram's NotchInfoUtils.java + */ +object NotchInfoUtils { + + data class NotchInfo( + val gravity: Int = Gravity.CENTER, + val isAccurate: Boolean = false, + val isLikelyCircle: Boolean = false, + val path: Path? = null, + val bounds: RectF = RectF(), + val rawPath: String = "" + ) + + private const val BOTTOM_MARKER = "@bottom" + private const val DP_MARKER = "@dp" + private const val RIGHT_MARKER = "@right" + private const val LEFT_MARKER = "@left" + + /** + * Get notch/cutout information from system resources + * Returns null if no notout found or Android < P + */ + fun getInfo(context: Context): NotchInfo? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return null + } + + try { + val res = context.resources + val resId = res.getIdentifier("config_mainBuiltInDisplayCutout", "string", "android") + if (resId == 0) return null + + var spec = context.getString(resId) + if (spec.isEmpty()) return null + spec = spec.trim() + + val metrics = res.displayMetrics + val displayWidth = metrics.widthPixels + val density = metrics.density + + // Determine gravity and offset + var gravity: Int + val offsetX: Float + + when { + spec.endsWith(RIGHT_MARKER) -> { + offsetX = displayWidth.toFloat() + spec = spec.substring(0, spec.length - RIGHT_MARKER.length).trim() + gravity = Gravity.END + } + spec.endsWith(LEFT_MARKER) -> { + offsetX = 0f + spec = spec.substring(0, spec.length - LEFT_MARKER.length).trim() + gravity = Gravity.START + } + else -> { + offsetX = displayWidth / 2f + gravity = Gravity.CENTER + } + } + + // Check if in dp + val inDp = spec.endsWith(DP_MARKER) + if (inDp) { + spec = spec.substring(0, spec.length - DP_MARKER.length) + } + + // Remove bottom marker if present + if (spec.contains(BOTTOM_MARKER)) { + spec = spec.split(BOTTOM_MARKER)[0].trim() + } + + // Parse path + val path = try { + val nodes = PathParser.createNodesFromPathData(spec) + Path().also { PathParser.PathDataNode.nodesToPath(nodes, it) } + } catch (e: Exception) { + return null + } + + // Transform path + val matrix = android.graphics.Matrix() + if (inDp) { + matrix.postScale(density, density) + } + matrix.postTranslate(offsetX, 0f) + path.transform(matrix) + + // Compute bounds + val bounds = RectF() + path.computeBounds(bounds, true) + + // Adjust gravity based on position + val dp2 = 2 * density + if (gravity != Gravity.CENTER && kotlin.math.abs(bounds.centerX() - displayWidth / 2f) <= dp2) { + gravity = Gravity.CENTER + } + if (gravity == Gravity.CENTER && bounds.left < displayWidth / 4f) { + gravity = Gravity.START + } + if (gravity == Gravity.CENTER && bounds.right > displayWidth / 4f * 3f) { + gravity = Gravity.END + } + + val dp32 = 32 * density + val isLikelyCircle = bounds.width() <= dp32 || bounds.width() <= bounds.height() + val isAccurate = spec.contains("C") || spec.contains("S") || spec.contains("Q") + + return NotchInfo( + gravity = gravity, + isAccurate = isAccurate, + isLikelyCircle = isLikelyCircle, + path = path, + bounds = bounds, + rawPath = spec + ) + } catch (e: Exception) { + return null + } + } +} 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 index 948103f..301ee3e 100644 --- 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 @@ -1,14 +1,18 @@ package com.rosetta.messenger.ui.components.metaball +import android.graphics.Path +import android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.graphics.Shader import android.os.Build +import android.view.Gravity import androidx.annotation.RequiresApi -import androidx.compose.foundation.background +import androidx.compose.foundation.Canvas 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 @@ -18,115 +22,125 @@ 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.asComposeRenderEffect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp +import org.intellij.lang.annotations.Language +import kotlin.math.abs +import kotlin.math.min import kotlin.math.sqrt +import androidx.compose.ui.graphics.Color as ComposeColor /** * Constants for the Profile Metaball Animation - * Avatar merges with actual device Dynamic Island / display cutout + * Based on Telegram's ProfileMetaballView implementation */ object ProfileMetaballConstants { - // Avatar dimensions (must match ProfileScreen constants) + // Avatar dimensions val AVATAR_SIZE_EXPANDED = 120.dp - val AVATAR_SIZE_MIN = 28.dp // Minimum size before disappearing into cutout + val AVATAR_SIZE_MIN = 24.dp - // Animation thresholds - const val MERGE_START_PROGRESS = 0.6f // When avatar starts fading - const val MERGE_COMPLETE_PROGRESS = 0.95f // When avatar fully disappears + // Animation thresholds + const val MERGE_START_PROGRESS = 0.5f + const val MERGE_COMPLETE_PROGRESS = 0.95f - // Blur settings for smooth edges - const val BLUR_RADIUS = 20f + // Blur settings for gooey effect (like Telegram's intensity = 15f) + const val BLUR_RADIUS = 15f const val CUTOFF = 0.5f + + // Fallback camera size if no notch detected (like status bar rect in Telegram) + val FALLBACK_CAMERA_SIZE = 12.dp } /** - * Computed state for the avatar blob position and size + * AGSL Shader for metaball alpha threshold effect + * Same as Telegram's ColorMatrixColorFilter with alpha threshold */ -private data class AvatarBlobState( - val x: Dp, - val y: Dp, - val width: Dp, - val height: Dp, - val cornerRadius: Dp, +@Language("AGSL") +private const val ProfileMetaballShaderSource = """ + uniform shader composable; + uniform float cutoff; + + half4 main(float2 fragCoord) { + half4 color = composable.eval(fragCoord); + float alpha = color.a; + + // Hard threshold for gooey effect + if (alpha > cutoff) { + alpha = 1.0; + } else { + alpha = 0.0; + } + + return half4(color.r, color.g, color.b, alpha); + } +""" + +/** + * State for avatar position and size during animation + */ +private data class AvatarState( + val centerX: Float, + val centerY: Float, + val radius: Float, val opacity: Float, val showBlob: Boolean ) /** - * Compute avatar blob state based on progress + * Compute avatar state based on collapse progress */ -private fun computeAvatarBlobState( +private fun computeAvatarState( collapseProgress: Float, expansionProgress: Float, - screenWidth: Dp, - statusBarHeight: Dp, - headerHeight: Dp, + screenWidthPx: Float, + statusBarHeightPx: Float, + headerHeightPx: Float, + avatarSizeExpandedPx: Float, + avatarSizeMinPx: Float, hasAvatar: Boolean -): AvatarBlobState { +): AvatarState { 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) + // Calculate radius + val radius: Float = when { hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { - width = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, screenWidth, sharpExpansion) - height = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, avatarZoneHeight, sharpExpansion) + // Expanding to full screen + val expandedRadius = screenWidthPx / 2f + lerpFloat(avatarSizeExpandedPx / 2f, expandedRadius, sharpExpansion) } - // Collapse: shrink to min size collapseProgress > 0f -> { + // Collapsing - shrink avatar 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 + lerpFloat(avatarSizeExpandedPx / 2f, avatarSizeMinPx / 2f, shrinkProgress) + } + else -> avatarSizeExpandedPx / 2f + } + + // Center X is 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) + + val centerY: Float = when { + hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { + lerpFloat(defaultCenterY, headerHeightPx / 2f, sharpExpansion) } else -> { - width = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED - height = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED + lerpFloat(defaultCenterY, targetY, collapseProgress) } } - 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 { + // Opacity + val opacity: Float = when { collapseProgress < ProfileMetaballConstants.MERGE_START_PROGRESS -> 1f collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f else -> { @@ -136,36 +150,22 @@ private fun computeAvatarBlobState( } } - val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && size > 1.dp + val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && radius > 1f - return AvatarBlobState( - x = x, - y = y, - width = width, - height = height, - cornerRadius = cornerRadius, - opacity = opacity, - showBlob = showBlob - ) + return AvatarState(centerX, centerY, radius, opacity, showBlob) +} + +private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { + return start + (stop - start) * fraction } /** - * Profile Metaball Overlay - Creates the liquid merge effect between avatar and island + * Profile Metaball Effect with real Bezier path - like Telegram! * - * 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 + * Creates a liquid "droplet" effect when scrolling up - the avatar + * stretches and merges with the camera/notch area using smooth curves. * - * 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 + * IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content! */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable @@ -175,136 +175,186 @@ fun ProfileMetaballOverlay( statusBarHeight: Dp, headerHeight: Dp, hasAvatar: Boolean, - @Suppress("UNUSED_PARAMETER") avatarColor: Color, + @Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor, modifier: Modifier = Modifier, avatarContent: @Composable BoxScope.() -> Unit = {}, ) { + val context = LocalContext.current val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp + val density = LocalDensity.current - val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) { + // Convert to pixels for path calculations + val screenWidthPx = with(density) { screenWidth.toPx() } + val statusBarHeightPx = with(density) { statusBarHeight.toPx() } + val headerHeightPx = with(density) { headerHeight.toPx() } + val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } + val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + + // Get REAL camera/notch info from system (like Telegram does) + val notchInfo = remember { NotchInfoUtils.getInfo(context) } + + // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) + val notchRadiusPx = remember(notchInfo) { + if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { + if (notchInfo.isLikelyCircle) { + // Circular camera cutout - use actual width/height + min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } else { + // Non-circular notch - use max dimension + kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } + } else { + // No notch info - use fallback (small circle like status bar) + with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } + } + } + + // Notch center position + val notchCenterX = remember(notchInfo, screenWidthPx) { + notchInfo?.bounds?.centerX() ?: (screenWidthPx / 2f) + } + + val notchCenterY = remember(notchInfo) { + if (notchInfo != null && notchInfo.isLikelyCircle) { + // For circle: center is at bottom - width/2 (like Telegram) + notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f + } else if (notchInfo != null) { + notchInfo.bounds.centerY() + } else { + statusBarHeightPx / 2f + } + } + + // Avatar state + val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) { derivedStateOf { - computeAvatarBlobState( + computeAvatarState( collapseProgress = collapseProgress, expansionProgress = expansionProgress, - screenWidth = screenWidth, - statusBarHeight = statusBarHeight, - headerHeight = headerHeight, + screenWidthPx = screenWidthPx, + statusBarHeightPx = statusBarHeightPx, + headerHeightPx = headerHeightPx, + avatarSizeExpandedPx = avatarSizeExpandedPx, + avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar ) } } - // MetaContainer applies blur + threshold for smooth edges - MetaContainer( - modifier = modifier.fillMaxSize(), - cutoff = ProfileMetaballConstants.CUTOFF - ) { - // Camera/Island blob - small circle at Dynamic Island position - // Only show when avatar is close enough to merge (create the droplet effect) - val showDroplet = collapseProgress > 0.3f && avatarState.showBlob - if (showDroplet) { - val islandSize = 40.dp // Small circle representing camera/island - val islandY = statusBarHeight / 2 - islandSize / 2 // Center of status bar - - MetaEntity( - modifier = Modifier.offset( - x = (screenWidth - islandSize) / 2, - y = islandY - ), - blur = ProfileMetaballConstants.BLUR_RADIUS, - metaContent = { - // Small black circle at camera position - Box( - modifier = Modifier - .size(islandSize) - .background( - color = Color.Black, - shape = RoundedCornerShape(islandSize / 2) // Perfect circle - ) - ) - }, - content = {} // No visible content, just the blur shape - ) - } - - // Avatar blob - moves up and shrinks on collapse - 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 + // Metaball shader + val metaShader = remember { RuntimeShader(ProfileMetaballShaderSource) } - val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) { - derivedStateOf { - computeAvatarBlobState( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - screenWidth = screenWidth, - statusBarHeight = statusBarHeight, - headerHeight = headerHeight, - hasAvatar = hasAvatar - ) - } - } + // Path for metaball connector + val metaballPath = remember { Path() } + val c1 = remember { Point() } // Notch center + val c2 = remember { Point() } // Avatar center + + // Calculate "v" parameter - thickness of connector based on distance + // Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0) + 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) + + // Should we draw the connector? (when avatar gets close enough) + val showConnector = collapseProgress > 0.3f && avatarState.showBlob && distance < maxDist * 2f Box(modifier = modifier.fillMaxSize()) { - // Simple avatar (no metaball effect, but keeps the animation) + // LAYER 1: Metaball shapes with blur effect (BLACK shapes only) + Canvas( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF) + renderEffect = RenderEffect + .createBlurEffect( + ProfileMetaballConstants.BLUR_RADIUS, + ProfileMetaballConstants.BLUR_RADIUS, + Shader.TileMode.DECAL + ) + .let { blurEffect -> + RenderEffect.createChainEffect( + RenderEffect.createRuntimeShaderEffect(metaShader, "composable"), + 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 + if (avatarState.showBlob) { + nativeCanvas.drawCircle( + avatarState.centerX, + avatarState.centerY, + avatarState.radius, + paint + ) + } + + // Draw metaball connector path + if (showConnector && avatarState.showBlob) { + c1.x = notchCenterX + c1.y = notchCenterY + c2.x = avatarState.centerX + c2.y = avatarState.centerY + + if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { + nativeCanvas.drawPath(metaballPath, paint) + } + } + } + } + + // LAYER 2: Actual avatar content (NO BLUR!) - rendered on top 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() } + Box( modifier = Modifier - .offset(x = avatarState.x, y = avatarState.y) - .width(avatarState.width) - .height(avatarState.height) - .clip(RoundedCornerShape(avatarState.cornerRadius)) - .background(avatarColor) + .offset(x = avatarOffsetX, y = avatarOffsetY) + .width(avatarSizeDp) + .height(avatarSizeDp) + .clip(RoundedCornerShape(avatarSizeDp / 2)) .graphicsLayer { alpha = avatarState.opacity }, @@ -316,7 +366,68 @@ fun ProfileMetaballOverlayCompat( } /** - * Wrapper that automatically chooses the right implementation based on Android version + * Compat version for older Android - simple animation without metaball effect + */ +@Composable +fun ProfileMetaballOverlayCompat( + collapseProgress: Float, + expansionProgress: Float, + statusBarHeight: Dp, + headerHeight: Dp, + hasAvatar: Boolean, + @Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor, + modifier: Modifier = Modifier, + avatarContent: @Composable BoxScope.() -> Unit = {}, +) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val density = LocalDensity.current + + val screenWidthPx = with(density) { screenWidth.toPx() } + val statusBarHeightPx = with(density) { statusBarHeight.toPx() } + val headerHeightPx = with(density) { headerHeight.toPx() } + val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } + val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + + val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) { + derivedStateOf { + computeAvatarState( + collapseProgress = collapseProgress, + expansionProgress = expansionProgress, + screenWidthPx = screenWidthPx, + statusBarHeightPx = statusBarHeightPx, + headerHeightPx = headerHeightPx, + avatarSizeExpandedPx = avatarSizeExpandedPx, + avatarSizeMinPx = avatarSizeMinPx, + hasAvatar = hasAvatar + ) + } + } + + 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() } + + Box( + modifier = Modifier + .offset(x = avatarOffsetX, y = avatarOffsetY) + .width(avatarSizeDp) + .height(avatarSizeDp) + .clip(RoundedCornerShape(avatarSizeDp / 2)) + .graphicsLayer { + alpha = avatarState.opacity + }, + contentAlignment = Alignment.Center, + content = avatarContent + ) + } + } +} + +/** + * Auto-selecting wrapper based on Android version */ @Composable fun ProfileMetaballEffect( @@ -325,7 +436,7 @@ fun ProfileMetaballEffect( statusBarHeight: Dp, headerHeight: Dp, hasAvatar: Boolean, - avatarColor: Color, + avatarColor: ComposeColor, modifier: Modifier = Modifier, avatarContent: @Composable BoxScope.() -> Unit = {}, ) {