feat: implement metaball path for avatar merging effect in ProfileMetaballOverlay
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
package com.rosetta.messenger.ui.components.metaball
|
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.os.Build
|
||||||
|
import android.view.Gravity
|
||||||
import androidx.annotation.RequiresApi
|
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.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,115 +22,125 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
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.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 kotlin.math.sqrt
|
||||||
|
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for the Profile Metaball Animation
|
* Constants for the Profile Metaball Animation
|
||||||
* Avatar merges with actual device Dynamic Island / display cutout
|
* Based on Telegram's ProfileMetaballView implementation
|
||||||
*/
|
*/
|
||||||
object ProfileMetaballConstants {
|
object ProfileMetaballConstants {
|
||||||
// Avatar dimensions (must match ProfileScreen constants)
|
// Avatar dimensions
|
||||||
val AVATAR_SIZE_EXPANDED = 120.dp
|
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
|
// Animation thresholds
|
||||||
const val MERGE_START_PROGRESS = 0.6f // When avatar starts fading
|
const val MERGE_START_PROGRESS = 0.5f
|
||||||
const val MERGE_COMPLETE_PROGRESS = 0.95f // When avatar fully disappears
|
const val MERGE_COMPLETE_PROGRESS = 0.95f
|
||||||
|
|
||||||
// Blur settings for smooth edges
|
// Blur settings for gooey effect (like Telegram's intensity = 15f)
|
||||||
const val BLUR_RADIUS = 20f
|
const val BLUR_RADIUS = 15f
|
||||||
const val CUTOFF = 0.5f
|
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(
|
@Language("AGSL")
|
||||||
val x: Dp,
|
private const val ProfileMetaballShaderSource = """
|
||||||
val y: Dp,
|
uniform shader composable;
|
||||||
val width: Dp,
|
uniform float cutoff;
|
||||||
val height: Dp,
|
|
||||||
val cornerRadius: Dp,
|
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 opacity: Float,
|
||||||
val showBlob: Boolean
|
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,
|
collapseProgress: Float,
|
||||||
expansionProgress: Float,
|
expansionProgress: Float,
|
||||||
screenWidth: Dp,
|
screenWidthPx: Float,
|
||||||
statusBarHeight: Dp,
|
statusBarHeightPx: Float,
|
||||||
headerHeight: Dp,
|
headerHeightPx: Float,
|
||||||
|
avatarSizeExpandedPx: Float,
|
||||||
|
avatarSizeMinPx: Float,
|
||||||
hasAvatar: Boolean
|
hasAvatar: Boolean
|
||||||
): AvatarBlobState {
|
): AvatarState {
|
||||||
val sharpExpansion = sqrt(expansionProgress.toDouble()).toFloat()
|
val sharpExpansion = sqrt(expansionProgress.toDouble()).toFloat()
|
||||||
|
|
||||||
// Avatar zone
|
// Calculate radius
|
||||||
val avatarZoneHeight = headerHeight
|
val radius: Float = when {
|
||||||
|
|
||||||
// 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 -> {
|
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
|
||||||
width = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, screenWidth, sharpExpansion)
|
// Expanding to full screen
|
||||||
height = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, avatarZoneHeight, sharpExpansion)
|
val expandedRadius = screenWidthPx / 2f
|
||||||
|
lerpFloat(avatarSizeExpandedPx / 2f, expandedRadius, sharpExpansion)
|
||||||
}
|
}
|
||||||
// Collapse: shrink to min size
|
|
||||||
collapseProgress > 0f -> {
|
collapseProgress > 0f -> {
|
||||||
|
// Collapsing - shrink avatar
|
||||||
val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f)
|
val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f)
|
||||||
val shrunkSize = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, ProfileMetaballConstants.AVATAR_SIZE_MIN, shrinkProgress)
|
lerpFloat(avatarSizeExpandedPx / 2f, avatarSizeMinPx / 2f, shrinkProgress)
|
||||||
width = shrunkSize
|
}
|
||||||
height = shrunkSize
|
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 -> {
|
else -> {
|
||||||
width = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
lerpFloat(defaultCenterY, targetY, collapseProgress)
|
||||||
height = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val size = if (width < height) width else height
|
// Opacity
|
||||||
|
val opacity: Float = when {
|
||||||
// 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_START_PROGRESS -> 1f
|
||||||
collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f
|
collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f
|
||||||
else -> {
|
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(
|
return AvatarState(centerX, centerY, radius, opacity, showBlob)
|
||||||
x = x,
|
}
|
||||||
y = y,
|
|
||||||
width = width,
|
private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
|
||||||
height = height,
|
return start + (stop - start) * fraction
|
||||||
cornerRadius = cornerRadius,
|
|
||||||
opacity = opacity,
|
|
||||||
showBlob = showBlob
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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:
|
* Creates a liquid "droplet" effect when scrolling up - the avatar
|
||||||
* - Expands to full screen on overscroll (pull down)
|
* stretches and merges with the camera/notch area using smooth curves.
|
||||||
* - 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.
|
* IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content!
|
||||||
*
|
|
||||||
* @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)
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -175,83 +175,186 @@ fun ProfileMetaballOverlay(
|
|||||||
statusBarHeight: Dp,
|
statusBarHeight: Dp,
|
||||||
headerHeight: Dp,
|
headerHeight: Dp,
|
||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
@Suppress("UNUSED_PARAMETER") avatarColor: Color,
|
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidth = configuration.screenWidthDp.dp
|
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 {
|
derivedStateOf {
|
||||||
computeAvatarBlobState(
|
computeAvatarState(
|
||||||
collapseProgress = collapseProgress,
|
collapseProgress = collapseProgress,
|
||||||
expansionProgress = expansionProgress,
|
expansionProgress = expansionProgress,
|
||||||
screenWidth = screenWidth,
|
screenWidthPx = screenWidthPx,
|
||||||
statusBarHeight = statusBarHeight,
|
statusBarHeightPx = statusBarHeightPx,
|
||||||
headerHeight = headerHeight,
|
headerHeightPx = headerHeightPx,
|
||||||
|
avatarSizeExpandedPx = avatarSizeExpandedPx,
|
||||||
|
avatarSizeMinPx = avatarSizeMinPx,
|
||||||
hasAvatar = hasAvatar
|
hasAvatar = hasAvatar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetaContainer applies blur + threshold for smooth edges
|
// Metaball shader
|
||||||
MetaContainer(
|
val metaShader = remember { RuntimeShader(ProfileMetaballShaderSource) }
|
||||||
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(
|
// Path for metaball connector
|
||||||
modifier = Modifier.offset(
|
val metaballPath = remember { Path() }
|
||||||
x = (screenWidth - islandSize) / 2,
|
val c1 = remember { Point() } // Notch center
|
||||||
y = islandY
|
val c2 = remember { Point() } // Avatar center
|
||||||
),
|
|
||||||
blur = ProfileMetaballConstants.BLUR_RADIUS,
|
// Calculate "v" parameter - thickness of connector based on distance
|
||||||
metaContent = {
|
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
|
||||||
// Small black circle at camera position
|
val distance = avatarState.centerY - notchCenterY
|
||||||
Box(
|
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()) {
|
||||||
|
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
|
||||||
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(islandSize)
|
.fillMaxSize()
|
||||||
.background(
|
.graphicsLayer {
|
||||||
color = Color.Black,
|
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
|
||||||
shape = RoundedCornerShape(islandSize / 2) // Perfect circle
|
renderEffect = RenderEffect
|
||||||
|
.createBlurEffect(
|
||||||
|
ProfileMetaballConstants.BLUR_RADIUS,
|
||||||
|
ProfileMetaballConstants.BLUR_RADIUS,
|
||||||
|
Shader.TileMode.DECAL
|
||||||
)
|
)
|
||||||
|
.let { blurEffect ->
|
||||||
|
RenderEffect.createChainEffect(
|
||||||
|
RenderEffect.createRuntimeShaderEffect(metaShader, "composable"),
|
||||||
|
blurEffect
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
content = {} // No visible content, just the blur shape
|
.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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar blob - moves up and shrinks on collapse
|
// 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) {
|
if (avatarState.showBlob) {
|
||||||
MetaEntity(
|
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
|
||||||
modifier = Modifier.offset(x = avatarState.x, y = avatarState.y),
|
val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() }
|
||||||
blur = ProfileMetaballConstants.BLUR_RADIUS,
|
val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() }
|
||||||
metaContent = {
|
|
||||||
// The blob shape for metaball effect
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(avatarState.width)
|
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||||
.height(avatarState.height)
|
.width(avatarSizeDp)
|
||||||
.background(
|
.height(avatarSizeDp)
|
||||||
color = Color.Black,
|
.clip(RoundedCornerShape(avatarSizeDp / 2))
|
||||||
shape = RoundedCornerShape(avatarState.cornerRadius)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = {
|
|
||||||
// Actual avatar content with fade
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(avatarState.width)
|
|
||||||
.height(avatarState.height)
|
|
||||||
.clip(RoundedCornerShape(avatarState.cornerRadius))
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = avatarState.opacity
|
alpha = avatarState.opacity
|
||||||
},
|
},
|
||||||
@@ -259,14 +362,11 @@ fun ProfileMetaballOverlay(
|
|||||||
content = avatarContent
|
content = avatarContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified version without MetaContainer for devices < Android 13
|
* Compat version for older Android - simple animation without metaball effect
|
||||||
* Falls back to simple avatar animation without the merge effect
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileMetaballOverlayCompat(
|
fun ProfileMetaballOverlayCompat(
|
||||||
@@ -275,36 +375,47 @@ fun ProfileMetaballOverlayCompat(
|
|||||||
statusBarHeight: Dp,
|
statusBarHeight: Dp,
|
||||||
headerHeight: Dp,
|
headerHeight: Dp,
|
||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
avatarColor: Color,
|
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidth = configuration.screenWidthDp.dp
|
val screenWidth = configuration.screenWidthDp.dp
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) {
|
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 {
|
derivedStateOf {
|
||||||
computeAvatarBlobState(
|
computeAvatarState(
|
||||||
collapseProgress = collapseProgress,
|
collapseProgress = collapseProgress,
|
||||||
expansionProgress = expansionProgress,
|
expansionProgress = expansionProgress,
|
||||||
screenWidth = screenWidth,
|
screenWidthPx = screenWidthPx,
|
||||||
statusBarHeight = statusBarHeight,
|
statusBarHeightPx = statusBarHeightPx,
|
||||||
headerHeight = headerHeight,
|
headerHeightPx = headerHeightPx,
|
||||||
|
avatarSizeExpandedPx = avatarSizeExpandedPx,
|
||||||
|
avatarSizeMinPx = avatarSizeMinPx,
|
||||||
hasAvatar = hasAvatar
|
hasAvatar = hasAvatar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
// Simple avatar (no metaball effect, but keeps the animation)
|
|
||||||
if (avatarState.showBlob) {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = avatarState.x, y = avatarState.y)
|
.offset(x = avatarOffsetX, y = avatarOffsetY)
|
||||||
.width(avatarState.width)
|
.width(avatarSizeDp)
|
||||||
.height(avatarState.height)
|
.height(avatarSizeDp)
|
||||||
.clip(RoundedCornerShape(avatarState.cornerRadius))
|
.clip(RoundedCornerShape(avatarSizeDp / 2))
|
||||||
.background(avatarColor)
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = avatarState.opacity
|
alpha = avatarState.opacity
|
||||||
},
|
},
|
||||||
@@ -316,7 +427,7 @@ fun ProfileMetaballOverlayCompat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper that automatically chooses the right implementation based on Android version
|
* Auto-selecting wrapper based on Android version
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileMetaballEffect(
|
fun ProfileMetaballEffect(
|
||||||
@@ -325,7 +436,7 @@ fun ProfileMetaballEffect(
|
|||||||
statusBarHeight: Dp,
|
statusBarHeight: Dp,
|
||||||
headerHeight: Dp,
|
headerHeight: Dp,
|
||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
avatarColor: Color,
|
avatarColor: ComposeColor,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user