feat: implement metaball effect for avatar merging in ProfileScreen
This commit is contained in:
@@ -54,6 +54,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
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.ImageCropHelper
|
||||
import compose.icons.TablerIcons
|
||||
@@ -717,9 +718,8 @@ private fun CollapsingProfileHeader(
|
||||
hasAvatar: Boolean,
|
||||
avatarRepository: AvatarRepository?
|
||||
) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp.dp
|
||||
|
||||
// Get actual status bar height
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
@@ -793,69 +793,10 @@ private fun CollapsingProfileHeader(
|
||||
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR - По умолчанию КРУГЛАЯ, при overscroll расширяется до прямоугольника
|
||||
// При collapse - уменьшается и уходит вверх
|
||||
// ТОЛЬКО ЕСЛИ ЕСТЬ АВАТАРКА! Без аватарки всегда круг
|
||||
// 👤 AVATAR - Размер шрифта для placeholder (без аватарки)
|
||||
// Основная логика теперь в ProfileMetaballEffect
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val circleSize = AVATAR_SIZE_EXPANDED
|
||||
// Зона аватарки = ВСЯ высота 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
|
||||
}
|
||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
|
||||
|
||||
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
|
||||
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 зоны, внутри блока
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -898,45 +837,38 @@ private fun CollapsingProfileHeader(
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR - Круг по умолчанию, квадрат при overscroll (ТОЛЬКО С АВАТАРКОЙ)
|
||||
// Без аватарки - всегда круглый placeholder как в sidebar
|
||||
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation on scroll
|
||||
// При скролле вверх аватарка "сливается" с Dynamic Island
|
||||
// Используем metaball эффект для плавного слияния форм
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (avatarSize > 1.dp) {
|
||||
if (hasAvatar) {
|
||||
// С аватаркой - расширяется до квадрата
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.offset(x = avatarX, y = avatarY)
|
||||
.size(width = avatarWidth, height = avatarHeight)
|
||||
.clip(RoundedCornerShape(cornerRadius)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (avatarRepository != null) {
|
||||
FullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Содержимое аватара
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
FullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// Без аватарки - ВСЕГДА круглый placeholder как в sidebar
|
||||
// Placeholder без аватарки
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.offset(x = avatarX, y = avatarY)
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(avatarColors.backgroundColor),
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (avatarFontSize > 1.sp) {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user