feat: implement metaball path for avatar merging effect in ProfileMetaballOverlay

This commit is contained in:
k1ngsterr1
2026-02-01 03:36:49 +05:00
parent c56b40f3f1
commit a61887cc5b
3 changed files with 588 additions and 209 deletions

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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 = {},
) {