feat: implement metaball effect for avatar merging in ProfileScreen
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.RuntimeShader
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import org.intellij.lang.annotations.Language
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AGSL Shader for metaball effect
|
||||||
|
*
|
||||||
|
* This shader creates the "liquid merge" effect by:
|
||||||
|
* 1. Taking already-blurred content as input
|
||||||
|
* 2. Applying alpha threshold cutoff (alpha > cutoff = opaque, else transparent)
|
||||||
|
*
|
||||||
|
* When two blurred shapes overlap, their alpha values combine,
|
||||||
|
* exceeding the threshold and creating a merged appearance.
|
||||||
|
*/
|
||||||
|
@Language("AGSL")
|
||||||
|
const val MetaballShaderSource = """
|
||||||
|
uniform shader composable;
|
||||||
|
uniform float cutoff;
|
||||||
|
|
||||||
|
half4 main(float2 fragCoord) {
|
||||||
|
half4 color = composable.eval(fragCoord);
|
||||||
|
float alpha = color.a;
|
||||||
|
|
||||||
|
// Hard threshold: if alpha > cutoff, make fully opaque, else transparent
|
||||||
|
if (alpha > cutoff) {
|
||||||
|
alpha = 1.0;
|
||||||
|
} else {
|
||||||
|
alpha = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return color with modified alpha
|
||||||
|
return half4(color.r, color.g, color.b, alpha);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container that applies the metaball shader effect to its children.
|
||||||
|
*
|
||||||
|
* Children should use [customBlur] modifier to be part of the metaball effect.
|
||||||
|
* When blurred shapes overlap, they appear to "merge" like liquid blobs.
|
||||||
|
*
|
||||||
|
* @param modifier Modifier for the container
|
||||||
|
* @param cutoff Alpha threshold (0-1). Higher = harder edges. Default 0.5
|
||||||
|
* @param content Composable content (should contain MetaEntity elements)
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
fun MetaContainer(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
cutoff: Float = 0.5f,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val metaShader = remember { RuntimeShader(MetaballShaderSource) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.graphicsLayer {
|
||||||
|
metaShader.setFloatUniform("cutoff", cutoff)
|
||||||
|
renderEffect = RenderEffect.createRuntimeShaderEffect(
|
||||||
|
metaShader, "composable"
|
||||||
|
).asComposeRenderEffect()
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifier that applies GPU-accelerated blur effect.
|
||||||
|
*
|
||||||
|
* Use this on shapes inside [MetaContainer] to create the metaball effect.
|
||||||
|
* The blur radius determines how "soft" the edges are before threshold is applied.
|
||||||
|
*
|
||||||
|
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
|
fun Modifier.customBlur(blur: Float) = this.then(
|
||||||
|
graphicsLayer {
|
||||||
|
if (blur > 0f) {
|
||||||
|
renderEffect = RenderEffect
|
||||||
|
.createBlurEffect(
|
||||||
|
blur,
|
||||||
|
blur,
|
||||||
|
Shader.TileMode.DECAL,
|
||||||
|
)
|
||||||
|
.asComposeRenderEffect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that wraps content with metaball-capable blur.
|
||||||
|
*
|
||||||
|
* This creates a layered effect:
|
||||||
|
* - metaContent: The blurred shape that participates in metaball merging
|
||||||
|
* - content: The actual visible content (not blurred)
|
||||||
|
*
|
||||||
|
* @param modifier Modifier for the entity
|
||||||
|
* @param blur Blur radius for the metaball effect
|
||||||
|
* @param metaContent The shape that will be blurred and merged (usually a solid color Box)
|
||||||
|
* @param content The visible content rendered on top
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
|
@Composable
|
||||||
|
fun MetaEntity(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
blur: Float = 30f,
|
||||||
|
metaContent: @Composable BoxScope.() -> Unit,
|
||||||
|
content: @Composable BoxScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
// Blurred layer for metaball effect
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.customBlur(blur),
|
||||||
|
content = metaContent,
|
||||||
|
)
|
||||||
|
// Visible content on top
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for the Profile Metaball Animation
|
||||||
|
* Avatar merges with actual device Dynamic Island / display cutout
|
||||||
|
*/
|
||||||
|
object ProfileMetaballConstants {
|
||||||
|
// Avatar dimensions (must match ProfileScreen constants)
|
||||||
|
val AVATAR_SIZE_EXPANDED = 120.dp
|
||||||
|
val AVATAR_SIZE_MIN = 28.dp // Minimum size before disappearing into cutout
|
||||||
|
|
||||||
|
// Animation thresholds
|
||||||
|
const val MERGE_START_PROGRESS = 0.6f // When avatar starts fading
|
||||||
|
const val MERGE_COMPLETE_PROGRESS = 0.95f // When avatar fully disappears
|
||||||
|
|
||||||
|
// Blur settings for smooth edges
|
||||||
|
const val BLUR_RADIUS = 20f
|
||||||
|
const val CUTOFF = 0.5f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed state for the avatar blob position and size
|
||||||
|
*/
|
||||||
|
private data class AvatarBlobState(
|
||||||
|
val x: Dp,
|
||||||
|
val y: Dp,
|
||||||
|
val width: Dp,
|
||||||
|
val height: Dp,
|
||||||
|
val cornerRadius: Dp,
|
||||||
|
val opacity: Float,
|
||||||
|
val showBlob: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute avatar blob state based on progress
|
||||||
|
*/
|
||||||
|
private fun computeAvatarBlobState(
|
||||||
|
collapseProgress: Float,
|
||||||
|
expansionProgress: Float,
|
||||||
|
screenWidth: Dp,
|
||||||
|
statusBarHeight: Dp,
|
||||||
|
headerHeight: Dp,
|
||||||
|
hasAvatar: Boolean
|
||||||
|
): AvatarBlobState {
|
||||||
|
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)
|
||||||
|
hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> {
|
||||||
|
width = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, screenWidth, sharpExpansion)
|
||||||
|
height = lerp(ProfileMetaballConstants.AVATAR_SIZE_EXPANDED, avatarZoneHeight, sharpExpansion)
|
||||||
|
}
|
||||||
|
// Collapse: shrink to min size
|
||||||
|
collapseProgress > 0f -> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
width = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
||||||
|
height = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
collapseProgress < ProfileMetaballConstants.MERGE_START_PROGRESS -> 1f
|
||||||
|
collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f
|
||||||
|
else -> {
|
||||||
|
val mergeProgress = (collapseProgress - ProfileMetaballConstants.MERGE_START_PROGRESS) /
|
||||||
|
(ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS - ProfileMetaballConstants.MERGE_START_PROGRESS)
|
||||||
|
(1f - mergeProgress).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && size > 1.dp
|
||||||
|
|
||||||
|
return AvatarBlobState(
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
opacity = opacity,
|
||||||
|
showBlob = showBlob
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile Metaball Overlay - Creates the liquid merge effect between avatar and island
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
fun ProfileMetaballOverlay(
|
||||||
|
collapseProgress: Float,
|
||||||
|
expansionProgress: Float,
|
||||||
|
statusBarHeight: Dp,
|
||||||
|
headerHeight: Dp,
|
||||||
|
hasAvatar: Boolean,
|
||||||
|
@Suppress("UNUSED_PARAMETER") avatarColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidth = configuration.screenWidthDp.dp
|
||||||
|
|
||||||
|
val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) {
|
||||||
|
derivedStateOf {
|
||||||
|
computeAvatarBlobState(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
screenWidth = screenWidth,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaContainer applies blur + threshold for smooth edges
|
||||||
|
MetaContainer(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
cutoff = ProfileMetaballConstants.CUTOFF
|
||||||
|
) {
|
||||||
|
// Avatar blob only - NO fake island
|
||||||
|
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
|
||||||
|
|
||||||
|
val avatarState by remember(collapseProgress, expansionProgress, screenWidth, statusBarHeight, headerHeight, hasAvatar) {
|
||||||
|
derivedStateOf {
|
||||||
|
computeAvatarBlobState(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
screenWidth = screenWidth,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
// Simple avatar (no metaball effect, but keeps the animation)
|
||||||
|
if (avatarState.showBlob) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = avatarState.x, y = avatarState.y)
|
||||||
|
.width(avatarState.width)
|
||||||
|
.height(avatarState.height)
|
||||||
|
.clip(RoundedCornerShape(avatarState.cornerRadius))
|
||||||
|
.background(avatarColor)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = avatarState.opacity
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
content = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper that automatically chooses the right implementation based on Android version
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProfileMetaballEffect(
|
||||||
|
collapseProgress: Float,
|
||||||
|
expansionProgress: Float,
|
||||||
|
statusBarHeight: Dp,
|
||||||
|
headerHeight: Dp,
|
||||||
|
hasAvatar: Boolean,
|
||||||
|
avatarColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ProfileMetaballOverlay(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
avatarColor = avatarColor,
|
||||||
|
modifier = modifier,
|
||||||
|
avatarContent = avatarContent
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProfileMetaballOverlayCompat(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
avatarColor = avatarColor,
|
||||||
|
modifier = modifier,
|
||||||
|
avatarContent = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
|
|||||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
|
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.rosetta.messenger.utils.ImageCropHelper
|
import com.rosetta.messenger.utils.ImageCropHelper
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
@@ -717,9 +718,8 @@ private fun CollapsingProfileHeader(
|
|||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
avatarRepository: AvatarRepository?
|
avatarRepository: AvatarRepository?
|
||||||
) {
|
) {
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val screenWidthDp = configuration.screenWidthDp.dp
|
|
||||||
|
|
||||||
// Get actual status bar height
|
// Get actual status bar height
|
||||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||||
@@ -793,69 +793,10 @@ private fun CollapsingProfileHeader(
|
|||||||
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
|
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 👤 AVATAR - По умолчанию КРУГЛАЯ, при overscroll расширяется до прямоугольника
|
// 👤 AVATAR - Размер шрифта для placeholder (без аватарки)
|
||||||
// При collapse - уменьшается и уходит вверх
|
// Основная логика теперь в ProfileMetaballEffect
|
||||||
// ТОЛЬКО ЕСЛИ ЕСТЬ АВАТАРКА! Без аватарки всегда круг
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val circleSize = AVATAR_SIZE_EXPANDED
|
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
|
||||||
// Зона аватарки = ВСЯ высота header включая статус бар
|
|
||||||
val avatarZoneHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
|
|
||||||
|
|
||||||
// Резкая анимация - используем easeOut (быстро в начале, медленно в конце)
|
|
||||||
// sqrt делает анимацию более резкой/быстрой
|
|
||||||
val sharpExpansion = kotlin.math.sqrt(expansionProgress.toDouble()).toFloat()
|
|
||||||
|
|
||||||
// При overscroll расширяем до прямоугольника на всю зону (только если не collapsed И есть
|
|
||||||
// аватарка)
|
|
||||||
val avatarWidth: Dp
|
|
||||||
val avatarHeight: Dp
|
|
||||||
|
|
||||||
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
|
|
||||||
// Overscroll: круг -> прямоугольник на всю зону ВКЛЮЧАЯ статус бар (ТОЛЬКО С АВАТАРКОЙ)
|
|
||||||
// Используем sharpExpansion для резкой анимации
|
|
||||||
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, sharpExpansion)
|
|
||||||
avatarHeight = androidx.compose.ui.unit.lerp(circleSize, avatarZoneHeight, sharpExpansion)
|
|
||||||
} else {
|
|
||||||
// Collapse: сразу начинаем уменьшаться от круга до 0
|
|
||||||
val collapsedSize = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseProgress)
|
|
||||||
avatarWidth = collapsedSize
|
|
||||||
avatarHeight = collapsedSize
|
|
||||||
}
|
|
||||||
|
|
||||||
val avatarSize = if (avatarWidth < avatarHeight) avatarWidth else avatarHeight
|
|
||||||
|
|
||||||
// Позиция X: всегда по центру
|
|
||||||
val avatarX = (screenWidthDp - avatarWidth) / 2
|
|
||||||
|
|
||||||
// Позиция Y
|
|
||||||
val availableHeight = avatarZoneHeight - statusBarHeight
|
|
||||||
val defaultCenterY = statusBarHeight + (availableHeight - avatarHeight) / 2
|
|
||||||
val topAvatarY = 0.dp // От самого верха экрана при полном expansion
|
|
||||||
|
|
||||||
val avatarY =
|
|
||||||
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
|
|
||||||
// При overscroll прижимаемся к самому верху (ТОЛЬКО С АВАТАРКОЙ)
|
|
||||||
// Используем sharpExpansion для резкой анимации
|
|
||||||
androidx.compose.ui.unit.lerp(defaultCenterY, topAvatarY, sharpExpansion)
|
|
||||||
} else {
|
|
||||||
// Collapse: сразу начинаем уходить вверх
|
|
||||||
androidx.compose.ui.unit.lerp(
|
|
||||||
defaultCenterY,
|
|
||||||
statusBarHeight - 80.dp,
|
|
||||||
collapseProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закругление: круг по умолчанию, при overscroll плавно становится квадратом (ТОЛЬКО С
|
|
||||||
// АВАТАРКОЙ)
|
|
||||||
val cornerRadius =
|
|
||||||
if (hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f) {
|
|
||||||
// Overscroll: круг -> квадрат без скругления (резкая анимация)
|
|
||||||
androidx.compose.ui.unit.lerp(avatarSize / 2, 0.dp, sharpExpansion)
|
|
||||||
} else {
|
|
||||||
// Всегда круг
|
|
||||||
avatarSize / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
|
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
@@ -870,8 +811,6 @@ private fun CollapsingProfileHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📝 TEXT - внизу header зоны, внутри блока
|
// 📝 TEXT - внизу header зоны, внутри блока
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -898,45 +837,38 @@ private fun CollapsingProfileHeader(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 👤 AVATAR - Круг по умолчанию, квадрат при overscroll (ТОЛЬКО С АВАТАРКОЙ)
|
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation on scroll
|
||||||
// Без аватарки - всегда круглый placeholder как в sidebar
|
// При скролле вверх аватарка "сливается" с Dynamic Island
|
||||||
|
// Используем metaball эффект для плавного слияния форм
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (avatarSize > 1.dp) {
|
ProfileMetaballEffect(
|
||||||
if (hasAvatar) {
|
collapseProgress = collapseProgress,
|
||||||
// С аватаркой - расширяется до квадрата
|
expansionProgress = expansionProgress,
|
||||||
Box(
|
statusBarHeight = statusBarHeight,
|
||||||
modifier =
|
headerHeight = headerHeight,
|
||||||
Modifier.offset(x = avatarX, y = avatarY)
|
hasAvatar = hasAvatar,
|
||||||
.size(width = avatarWidth, height = avatarHeight)
|
avatarColor = avatarColors.backgroundColor,
|
||||||
.clip(RoundedCornerShape(cornerRadius)),
|
modifier = Modifier.fillMaxSize()
|
||||||
contentAlignment = Alignment.Center
|
) {
|
||||||
) {
|
// Содержимое аватара
|
||||||
if (avatarRepository != null) {
|
if (hasAvatar && avatarRepository != null) {
|
||||||
FullSizeAvatar(
|
FullSizeAvatar(
|
||||||
publicKey = publicKey,
|
publicKey = publicKey,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Без аватарки - ВСЕГДА круглый placeholder как в sidebar
|
// Placeholder без аватарки
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||||
Modifier.offset(x = avatarX, y = avatarY)
|
|
||||||
.size(avatarSize)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(avatarColors.backgroundColor),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (avatarFontSize > 1.sp) {
|
Text(
|
||||||
Text(
|
text = getInitials(name),
|
||||||
text = getInitials(name),
|
fontSize = avatarFontSize,
|
||||||
fontSize = avatarFontSize,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.Bold,
|
color = avatarColors.textColor
|
||||||
color = avatarColors.textColor
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user