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
|
||||
|
||||
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 = {},
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user