fix: fix gooey animation
This commit is contained in:
@@ -13,6 +13,20 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
data class Point(var x: Float = 0f, var y: Float = 0f)
|
data class Point(var x: Float = 0f, var y: Float = 0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable scratch points to avoid per-frame allocations during scroll.
|
||||||
|
*/
|
||||||
|
class MetaballWorkspace {
|
||||||
|
val p1 = Point()
|
||||||
|
val p2 = Point()
|
||||||
|
val p3 = Point()
|
||||||
|
val p4 = Point()
|
||||||
|
val h1 = Point()
|
||||||
|
val h2 = Point()
|
||||||
|
val h3 = Point()
|
||||||
|
val h4 = Point()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a metaball path between two circles - exact port from Telegram's ProfileMetaballView.java
|
* Creates a metaball path between two circles - exact port from Telegram's ProfileMetaballView.java
|
||||||
*
|
*
|
||||||
@@ -33,28 +47,18 @@ fun createMetaballPath(
|
|||||||
radius1: Float,
|
radius1: Float,
|
||||||
radius2: Float,
|
radius2: Float,
|
||||||
v: Float,
|
v: Float,
|
||||||
path: Path
|
path: Path,
|
||||||
|
workspace: MetaballWorkspace = MetaballWorkspace()
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val HALF_PI = (Math.PI / 2).toFloat()
|
val HALF_PI = (Math.PI / 2).toFloat()
|
||||||
val HANDLE_SIZE = 20.0f
|
val HANDLE_SIZE = 3.6f
|
||||||
|
|
||||||
// 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
|
// Distance between centers
|
||||||
val d = dist(c1, c2)
|
val d = dist(c1, c2)
|
||||||
val maxDist = radius1 + radius2 * 2.25f
|
val maxDist = radius1 + radius2 * 2.32f
|
||||||
var u1 = 0f
|
var u1 = 0f
|
||||||
var u2 = 0f
|
var u2 = 0f
|
||||||
|
val connector = v.coerceIn(0f, 1f)
|
||||||
|
|
||||||
// Check if circles can be connected
|
// Check if circles can be connected
|
||||||
if (radius1 == 0f || radius2 == 0f || d > maxDist || d <= abs(radius1 - radius2)) {
|
if (radius1 == 0f || radius2 == 0f || d > maxDist || d <= abs(radius1 - radius2)) {
|
||||||
@@ -63,18 +67,29 @@ fun createMetaballPath(
|
|||||||
|
|
||||||
// Calculate overlap angles
|
// Calculate overlap angles
|
||||||
if (d < radius1 + radius2) {
|
if (d < radius1 + radius2) {
|
||||||
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d))
|
val acosArg1 = ((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)).coerceIn(-1f, 1f)
|
||||||
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d))
|
val acosArg2 = ((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)).coerceIn(-1f, 1f)
|
||||||
|
u1 = acos(acosArg1)
|
||||||
|
u2 = acos(acosArg2)
|
||||||
}
|
}
|
||||||
|
|
||||||
val angleBetweenCenters = angle(c2, c1)
|
val angleBetweenCenters = angle(c2, c1)
|
||||||
val maxSpread = acos((radius1 - radius2) / d)
|
val maxSpread = acos(((radius1 - radius2) / d).coerceIn(-1f, 1f))
|
||||||
|
|
||||||
// Calculate connection angles
|
// Calculate connection angles
|
||||||
val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v
|
val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * connector
|
||||||
val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v
|
val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * connector
|
||||||
val angle3 = angleBetweenCenters + Math.PI.toFloat() - u2 - (Math.PI.toFloat() - u2 - maxSpread) * v
|
val angle3 = angleBetweenCenters + Math.PI.toFloat() - u2 - (Math.PI.toFloat() - u2 - maxSpread) * connector
|
||||||
val angle4 = angleBetweenCenters - Math.PI.toFloat() + u2 + (Math.PI.toFloat() - u2 - maxSpread) * v
|
val angle4 = angleBetweenCenters - Math.PI.toFloat() + u2 + (Math.PI.toFloat() - u2 - maxSpread) * connector
|
||||||
|
|
||||||
|
val p1 = workspace.p1
|
||||||
|
val p2 = workspace.p2
|
||||||
|
val p3 = workspace.p3
|
||||||
|
val p4 = workspace.p4
|
||||||
|
val h1 = workspace.h1
|
||||||
|
val h2 = workspace.h2
|
||||||
|
val h3 = workspace.h3
|
||||||
|
val h4 = workspace.h4
|
||||||
|
|
||||||
// Get points on circle edges
|
// Get points on circle edges
|
||||||
getVector(c1, angle1, radius1, p1)
|
getVector(c1, angle1, radius1, p1)
|
||||||
@@ -84,7 +99,7 @@ fun createMetaballPath(
|
|||||||
|
|
||||||
// Calculate handle lengths for Bezier curves
|
// Calculate handle lengths for Bezier curves
|
||||||
val totalRadius = radius1 + radius2
|
val totalRadius = radius1 + radius2
|
||||||
val d2Base = minOf(v * HANDLE_SIZE, dist(p1, p3) / totalRadius)
|
val d2Base = minOf(connector * HANDLE_SIZE, dist(p1, p3) / totalRadius)
|
||||||
val d2 = d2Base * minOf(1f, d * 2 / totalRadius)
|
val d2 = d2Base * minOf(1f, d * 2 / totalRadius)
|
||||||
|
|
||||||
val r1 = radius1 * d2
|
val r1 = radius1 * d2
|
||||||
@@ -100,13 +115,9 @@ fun createMetaballPath(
|
|||||||
path.rewind()
|
path.rewind()
|
||||||
path.moveTo(p1.x, p1.y)
|
path.moveTo(p1.x, p1.y)
|
||||||
path.cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y)
|
path.cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y)
|
||||||
|
path.lineTo(p4.x, p4.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.cubicTo(h4.x, h4.y, h2.x, h2.y, p2.x, p2.y)
|
||||||
|
path.lineTo(p1.x, p1.y)
|
||||||
path.close()
|
path.close()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ object ProfileMetaballConstants {
|
|||||||
const val MERGE_START_PROGRESS = 0.5f
|
const val MERGE_START_PROGRESS = 0.5f
|
||||||
const val MERGE_COMPLETE_PROGRESS = 0.99f // Let opacity handle fade, don't cut off early
|
const val MERGE_COMPLETE_PROGRESS = 0.99f // Let opacity handle fade, don't cut off early
|
||||||
|
|
||||||
// Blur settings for gooey effect (like Telegram's intensity = 15f)
|
// Slightly stronger blur because our avatar base size is larger than Telegram's 96dp.
|
||||||
const val BLUR_RADIUS = 15f
|
const val BLUR_RADIUS = 18f
|
||||||
|
|
||||||
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
|
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
|
||||||
const val BLUR_MIN = 2f
|
const val BLUR_MIN = 2f
|
||||||
@@ -218,7 +218,8 @@ private fun computeAvatarState(
|
|||||||
collapseProgress > 0f -> {
|
collapseProgress > 0f -> {
|
||||||
// Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
|
// Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
|
||||||
// vr = baseWidth * avatarScale * 0.5
|
// vr = baseWidth * avatarScale * 0.5
|
||||||
val intoSize = notchRadiusPx * 2f // Target size = notch diameter
|
// Keep a minimum collapsed size so tiny punch-hole cameras don't make the blob too small.
|
||||||
|
val intoSize = max(avatarSizeMinPx, notchRadiusPx * 2f)
|
||||||
val normalSize = avatarSizeExpandedPx
|
val normalSize = avatarSizeExpandedPx
|
||||||
val avatarWidth = lerpFloat(intoSize, normalSize, diff)
|
val avatarWidth = lerpFloat(intoSize, normalSize, diff)
|
||||||
avatarWidth / 2f // radius = half of width
|
avatarWidth / 2f // radius = half of width
|
||||||
@@ -343,6 +344,12 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
|
|||||||
return start + (stop - start) * fraction
|
return start + (stop - start) * fraction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun easeOutCubic(value: Float): Float {
|
||||||
|
val t = value.coerceIn(0f, 1f)
|
||||||
|
val inv = 1f - t
|
||||||
|
return 1f - inv * inv * inv
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile Metaball Effect with real Bezier path - like Telegram!
|
* Profile Metaball Effect with real Bezier path - like Telegram!
|
||||||
*
|
*
|
||||||
@@ -370,9 +377,6 @@ fun ProfileMetaballOverlay(
|
|||||||
val screenWidth = configuration.screenWidthDp.dp
|
val screenWidth = configuration.screenWidthDp.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
// Debug: log screen dimensions once
|
|
||||||
val TAG = "ProfileMetaball"
|
|
||||||
|
|
||||||
// Convert to pixels for path calculations
|
// Convert to pixels for path calculations
|
||||||
val screenWidthPx = with(density) { screenWidth.toPx() }
|
val screenWidthPx = with(density) { screenWidth.toPx() }
|
||||||
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
|
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
|
||||||
@@ -386,10 +390,13 @@ fun ProfileMetaballOverlay(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug log notch info once
|
// Only log in explicit debug mode to keep production scroll clean.
|
||||||
LaunchedEffect(notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
||||||
Log.d(TAG, "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
|
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
||||||
Log.d(TAG, "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
|
if (debugLogsEnabled) {
|
||||||
|
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
|
||||||
|
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||||
@@ -431,12 +438,13 @@ fun ProfileMetaballOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram thresholds in pixels
|
// Scale Telegram thresholds to our bigger base avatar size (120dp vs 96dp).
|
||||||
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
|
val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
|
||||||
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
|
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
|
||||||
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
|
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
|
||||||
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
|
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
|
||||||
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
|
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
|
||||||
|
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
|
||||||
|
|
||||||
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
||||||
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
|
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
|
||||||
@@ -469,9 +477,16 @@ fun ProfileMetaballOverlay(
|
|||||||
val metaballPath = remember { Path() }
|
val metaballPath = remember { Path() }
|
||||||
val c1 = remember { Point() } // Notch center
|
val c1 = remember { Point() } // Notch center
|
||||||
val c2 = remember { Point() } // Avatar center
|
val c2 = remember { Point() } // Avatar center
|
||||||
|
val metaballWorkspace = remember { MetaballWorkspace() }
|
||||||
|
|
||||||
// Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
// Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
||||||
val rectTmp = remember { RectF() }
|
val rectTmp = remember { RectF() }
|
||||||
|
val blackPaint = remember {
|
||||||
|
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = android.graphics.Color.BLACK
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR)
|
// Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR)
|
||||||
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
@@ -484,24 +499,36 @@ fun ProfileMetaballOverlay(
|
|||||||
val distance = avatarState.centerY - notchCenterY
|
val distance = avatarState.centerY - notchCenterY
|
||||||
val maxDist = avatarSizeExpandedPx
|
val maxDist = avatarSizeExpandedPx
|
||||||
val c = (distance / maxDist).coerceIn(-1f, 1f)
|
val c = (distance / maxDist).coerceIn(-1f, 1f)
|
||||||
|
val connectorProgressRaw = ((dp40 - avatarState.radius) / (dp40 - dp18)).coerceIn(0f, 1f)
|
||||||
|
val connectorProgress = easeOutCubic(connectorProgressRaw)
|
||||||
|
val layerVisibilityBase = easeOutCubic((collapseProgress / 0.08f).coerceIn(0f, 1f))
|
||||||
|
val layerVisibility = lerpFloat(0.35f, 1f, layerVisibilityBase)
|
||||||
|
|
||||||
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
||||||
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
||||||
val v = if (!avatarState.isNear) {
|
val vBase = if (!avatarState.isNear) {
|
||||||
min(lerpFloat(0f, 0.2f, near), baseV)
|
min(lerpFloat(0f, 0.2f, near), baseV)
|
||||||
} else {
|
} else {
|
||||||
baseV
|
baseV
|
||||||
}
|
}.coerceIn(0.08f, 0.90f)
|
||||||
|
val v = lerpFloat(vBase * 0.92f, (vBase + 0.14f).coerceAtMost(0.98f), connectorProgress)
|
||||||
|
.coerceIn(0.08f, 0.98f)
|
||||||
|
|
||||||
// Show connector when avatar is small enough (isDrawing) and not expanding
|
// Show connector when avatar is small enough (isDrawing) and not expanding
|
||||||
// No longer requires hasRealNotch — works with black bar fallback too
|
// No longer requires hasRealNotch — works with black bar fallback too
|
||||||
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
val showConnector =
|
||||||
|
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
|
||||||
|
distance < maxDist * 1.6f && connectorProgress > 0.001f
|
||||||
|
|
||||||
// Show metaball layer when collapsing with avatar
|
// Show metaball layer when collapsing with avatar
|
||||||
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
val showMetaballLayer =
|
||||||
|
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
|
||||||
|
collapseProgress > 0.015f && layerVisibility > 0.01f
|
||||||
|
val connectorPaintAlpha = lerpFloat(96f, 255f, connectorProgress).toInt().coerceIn(0, 255)
|
||||||
|
|
||||||
// Adjusted blur radius based on device performance (factorMult)
|
// Adjusted blur radius based on device performance (factorMult)
|
||||||
val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult
|
val adjustedBlurRadius = (ProfileMetaballConstants.BLUR_RADIUS / factorMult) *
|
||||||
|
lerpFloat(0.9f, 1.45f, connectorProgress)
|
||||||
|
|
||||||
Box(modifier = modifier
|
Box(modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -512,6 +539,9 @@ fun ProfileMetaballOverlay(
|
|||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = layerVisibility
|
||||||
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
// ColorMatrixColorFilter for alpha threshold (API 31+)
|
// ColorMatrixColorFilter for alpha threshold (API 31+)
|
||||||
// Replaces AGSL RuntimeShader — same gooey effect
|
// Replaces AGSL RuntimeShader — same gooey effect
|
||||||
@@ -528,38 +558,35 @@ fun ProfileMetaballOverlay(
|
|||||||
.asComposeRenderEffect()
|
.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 ->
|
drawIntoCanvas { canvas ->
|
||||||
val nativeCanvas = canvas.nativeCanvas
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
|
|
||||||
// Draw target shape at top (notch or black bar fallback)
|
// Draw target shape at top (notch or black bar fallback)
|
||||||
if (showConnector) {
|
if (showConnector) {
|
||||||
|
blackPaint.alpha = connectorPaintAlpha
|
||||||
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||||
nativeCanvas.drawCircle(
|
nativeCanvas.drawCircle(
|
||||||
notchCenterX,
|
notchCenterX,
|
||||||
notchCenterY,
|
notchCenterY,
|
||||||
notchRadiusPx,
|
notchRadiusPx,
|
||||||
paint
|
blackPaint
|
||||||
)
|
)
|
||||||
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
||||||
nativeCanvas.drawPath(notchInfo.path, paint)
|
nativeCanvas.drawPath(notchInfo.path, blackPaint)
|
||||||
} else if (hasRealNotch && notchInfo != null) {
|
} else if (hasRealNotch && notchInfo != null) {
|
||||||
val bounds = notchInfo.bounds
|
val bounds = notchInfo.bounds
|
||||||
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
||||||
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
|
nativeCanvas.drawRoundRect(bounds, rad, rad, blackPaint)
|
||||||
} else {
|
} else {
|
||||||
// No notch fallback: full-width black bar at top
|
// No notch fallback: full-width black bar at top
|
||||||
// Like Telegram's ProfileGooeyView when notchInfo == null
|
// Like Telegram's ProfileGooeyView when notchInfo == null
|
||||||
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint)
|
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw avatar shape - circle or rounded rect depending on state
|
// Draw avatar shape - circle or rounded rect depending on state
|
||||||
if (avatarState.showBlob) {
|
if (avatarState.showBlob) {
|
||||||
|
blackPaint.alpha = 255
|
||||||
val cx = avatarState.centerX
|
val cx = avatarState.centerX
|
||||||
val cy = avatarState.centerY
|
val cy = avatarState.centerY
|
||||||
val r = avatarState.radius
|
val r = avatarState.radius
|
||||||
@@ -569,26 +596,28 @@ fun ProfileMetaballOverlay(
|
|||||||
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
|
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
|
||||||
if (cornerR >= r * 0.95f) {
|
if (cornerR >= r * 0.95f) {
|
||||||
// Draw circle
|
// Draw circle
|
||||||
nativeCanvas.drawCircle(cx, cy, r, paint)
|
nativeCanvas.drawCircle(cx, cy, r, blackPaint)
|
||||||
} else {
|
} else {
|
||||||
// Draw rounded rect (like Telegram when isDrawing)
|
// Draw rounded rect (like Telegram when isDrawing)
|
||||||
// Reuse rectTmp to avoid allocations
|
// Reuse rectTmp to avoid allocations
|
||||||
rectTmp.set(cx - r, cy - r, cx + r, cy + r)
|
rectTmp.set(cx - r, cy - r, cx + r, cy + r)
|
||||||
nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, paint)
|
nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, blackPaint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw metaball connector path
|
// Draw metaball connector path
|
||||||
if (showConnector && avatarState.showBlob) {
|
if (showConnector && avatarState.showBlob) {
|
||||||
|
blackPaint.alpha = connectorPaintAlpha
|
||||||
c1.x = notchCenterX
|
c1.x = notchCenterX
|
||||||
c1.y = notchCenterY
|
c1.y = notchCenterY
|
||||||
c2.x = avatarState.centerX
|
c2.x = avatarState.centerX
|
||||||
c2.y = avatarState.centerY
|
c2.y = avatarState.centerY
|
||||||
|
|
||||||
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) {
|
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath, metaballWorkspace)) {
|
||||||
nativeCanvas.drawPath(metaballPath, paint)
|
nativeCanvas.drawPath(metaballPath, blackPaint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
blackPaint.alpha = 255
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // END if (showMetaballLayer)
|
} // END if (showMetaballLayer)
|
||||||
@@ -717,12 +746,12 @@ fun ProfileMetaballOverlayCompat(
|
|||||||
val notchCenterY = blackBarHeightPx / 2f
|
val notchCenterY = blackBarHeightPx / 2f
|
||||||
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
||||||
|
|
||||||
// Telegram thresholds in pixels
|
val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
|
||||||
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
|
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
|
||||||
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
|
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
|
||||||
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
|
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
|
||||||
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
|
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
|
||||||
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
|
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
|
||||||
|
|
||||||
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
||||||
// derivedStateOf автоматически отслеживает их как зависимости
|
// derivedStateOf автоматически отслеживает их как зависимости
|
||||||
@@ -894,12 +923,12 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thresholds
|
val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
|
||||||
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
|
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
|
||||||
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
|
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
|
||||||
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
|
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
|
||||||
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
|
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
|
||||||
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
|
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
|
||||||
|
|
||||||
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
|
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -925,22 +954,35 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
val metaballPath = remember { android.graphics.Path() }
|
val metaballPath = remember { android.graphics.Path() }
|
||||||
val c1 = remember { Point() }
|
val c1 = remember { Point() }
|
||||||
val c2 = remember { Point() }
|
val c2 = remember { Point() }
|
||||||
|
val metaballWorkspace = remember { MetaballWorkspace() }
|
||||||
val rectTmp = remember { RectF() }
|
val rectTmp = remember { RectF() }
|
||||||
|
|
||||||
// Connector calculations
|
// Connector calculations
|
||||||
val distance = avatarState.centerY - notchCenterY
|
val distance = avatarState.centerY - notchCenterY
|
||||||
val maxDist = avatarSizeExpandedPx
|
val maxDist = avatarSizeExpandedPx
|
||||||
|
val connectorProgressRaw = ((dp40 - avatarState.radius) / (dp40 - dp18)).coerceIn(0f, 1f)
|
||||||
|
val connectorProgress = easeOutCubic(connectorProgressRaw)
|
||||||
|
val layerVisibilityBase = easeOutCubic((collapseProgress / 0.08f).coerceIn(0f, 1f))
|
||||||
|
val layerVisibility = lerpFloat(0.35f, 1f, layerVisibilityBase)
|
||||||
val cParam = (distance / maxDist).coerceIn(-1f, 1f)
|
val cParam = (distance / maxDist).coerceIn(-1f, 1f)
|
||||||
val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
||||||
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
||||||
val v = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV
|
val vBase = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV
|
||||||
|
val vBaseClamped = vBase.coerceIn(0.08f, 0.90f)
|
||||||
|
val v = lerpFloat(vBaseClamped * 0.92f, (vBaseClamped + 0.14f).coerceAtMost(0.98f), connectorProgress)
|
||||||
|
.coerceIn(0.08f, 0.98f)
|
||||||
|
|
||||||
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
val showConnector =
|
||||||
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
|
||||||
|
distance < maxDist * 1.6f && connectorProgress > 0.001f
|
||||||
|
val showMetaballLayer =
|
||||||
|
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
|
||||||
|
collapseProgress > 0.015f && layerVisibility > 0.01f
|
||||||
|
val connectorPaintAlpha = lerpFloat(96f, 255f, connectorProgress).toInt().coerceIn(0, 255)
|
||||||
|
|
||||||
// CPU-specific: downscaled bitmap for blur
|
// CPU-specific: downscaled bitmap for blur
|
||||||
// Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold
|
// Higher internal resolution for cleaner, denser metaball edges.
|
||||||
val scaleConst = 3f
|
val scaleConst = 2.5f
|
||||||
val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt())
|
val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt())
|
||||||
val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt())
|
val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt())
|
||||||
val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1)
|
val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1)
|
||||||
@@ -982,7 +1024,13 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
) {
|
) {
|
||||||
// LAYER 1: CPU-rendered metaball shapes
|
// LAYER 1: CPU-rendered metaball shapes
|
||||||
if (showMetaballLayer) {
|
if (showMetaballLayer) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = layerVisibility
|
||||||
|
}
|
||||||
|
) {
|
||||||
drawIntoCanvas { canvas ->
|
drawIntoCanvas { canvas ->
|
||||||
val nativeCanvas = canvas.nativeCanvas
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f
|
val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f
|
||||||
@@ -1000,6 +1048,7 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
|
|
||||||
// Draw target (notch or black bar)
|
// Draw target (notch or black bar)
|
||||||
if (showConnector) {
|
if (showConnector) {
|
||||||
|
blackPaint.alpha = connectorPaintAlpha
|
||||||
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||||
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
|
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
|
||||||
offscreenCanvas.drawCircle(
|
offscreenCanvas.drawCircle(
|
||||||
@@ -1021,6 +1070,7 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
|
|
||||||
// Draw avatar shape
|
// Draw avatar shape
|
||||||
if (avatarState.showBlob) {
|
if (avatarState.showBlob) {
|
||||||
|
blackPaint.alpha = 255
|
||||||
val cx = avatarState.centerX
|
val cx = avatarState.centerX
|
||||||
val cy = avatarState.centerY
|
val cy = avatarState.centerY
|
||||||
val r = avatarState.radius
|
val r = avatarState.radius
|
||||||
@@ -1036,20 +1086,23 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
|
|
||||||
// Draw metaball connector
|
// Draw metaball connector
|
||||||
if (showConnector && avatarState.showBlob) {
|
if (showConnector && avatarState.showBlob) {
|
||||||
|
blackPaint.alpha = connectorPaintAlpha
|
||||||
c1.x = notchCenterX
|
c1.x = notchCenterX
|
||||||
c1.y = notchCenterY
|
c1.y = notchCenterY
|
||||||
c2.x = avatarState.centerX
|
c2.x = avatarState.centerX
|
||||||
c2.y = avatarState.centerY
|
c2.y = avatarState.centerY
|
||||||
|
|
||||||
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) {
|
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath, metaballWorkspace)) {
|
||||||
offscreenCanvas.drawPath(metaballPath, blackPaint)
|
offscreenCanvas.drawPath(metaballPath, blackPaint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
blackPaint.alpha = 255
|
||||||
|
|
||||||
offscreenCanvas.restore()
|
offscreenCanvas.restore()
|
||||||
|
|
||||||
// Apply stack blur on CPU
|
// Apply stack blur on CPU
|
||||||
val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1)
|
val blurRadius = ((ProfileMetaballConstants.BLUR_RADIUS * 2f / scaleConst) *
|
||||||
|
lerpFloat(0.9f, 1.45f, connectorProgress)).toInt().coerceAtLeast(1)
|
||||||
stackBlurBitmapInPlace(offscreenBitmap, blurRadius)
|
stackBlurBitmapInPlace(offscreenBitmap, blurRadius)
|
||||||
|
|
||||||
// Draw blurred bitmap with color matrix filter (alpha threshold)
|
// Draw blurred bitmap with color matrix filter (alpha threshold)
|
||||||
@@ -1229,9 +1282,8 @@ fun MetaballDebugPanel(modifier: Modifier = Modifier) {
|
|||||||
"cpu" -> "CPU (forced)"
|
"cpu" -> "CPU (forced)"
|
||||||
"compat" -> "Compat (forced)"
|
"compat" -> "Compat (forced)"
|
||||||
else -> when {
|
else -> when {
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perfClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
||||||
perfClass >= PerformanceClass.HIGH -> "CPU (auto)"
|
else -> "CPU (auto)"
|
||||||
else -> "Compat (auto)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -1287,13 +1339,15 @@ fun ProfileMetaballEffect(
|
|||||||
"cpu" -> "CPU (forced)"
|
"cpu" -> "CPU (forced)"
|
||||||
"compat" -> "Compat (forced)"
|
"compat" -> "Compat (forced)"
|
||||||
else -> when {
|
else -> when {
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
||||||
performanceClass >= PerformanceClass.HIGH -> "CPU (auto)"
|
else -> "CPU (auto)"
|
||||||
else -> "Compat (auto)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(selectedPath) {
|
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
||||||
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
|
||||||
|
if (debugLogsEnabled) {
|
||||||
|
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve actual mode
|
// Resolve actual mode
|
||||||
@@ -1301,18 +1355,22 @@ fun ProfileMetaballEffect(
|
|||||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
||||||
"cpu" -> false
|
"cpu" -> false
|
||||||
"compat" -> false
|
"compat" -> false
|
||||||
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE
|
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
}
|
}
|
||||||
val useCpu = when (MetaballDebug.forceMode) {
|
val useCpu = when (MetaballDebug.forceMode) {
|
||||||
"gpu" -> false
|
"gpu" -> false
|
||||||
"cpu" -> true
|
"cpu" -> true
|
||||||
"compat" -> false
|
"compat" -> false
|
||||||
else -> !useGpu && performanceClass >= PerformanceClass.HIGH
|
else -> !useGpu
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
useGpu -> {
|
useGpu -> {
|
||||||
val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f
|
val factorMult = when (performanceClass) {
|
||||||
|
PerformanceClass.HIGH -> 1f
|
||||||
|
PerformanceClass.AVERAGE -> 1.2f
|
||||||
|
PerformanceClass.LOW -> 1.35f
|
||||||
|
}
|
||||||
ProfileMetaballOverlay(
|
ProfileMetaballOverlay(
|
||||||
collapseProgress = collapseProgress,
|
collapseProgress = collapseProgress,
|
||||||
expansionProgress = expansionProgress,
|
expansionProgress = expansionProgress,
|
||||||
|
|||||||
Reference in New Issue
Block a user