fix: enhance avatar expansion and collapse animations with overscroll support and haptic feedback in OtherProfileScreen

This commit is contained in:
2026-02-02 01:50:00 +05:00
parent f78bd0edeb
commit e1cc49c12b
6 changed files with 1106 additions and 523 deletions

View File

@@ -1,6 +1,11 @@
package com.rosetta.messenger.ui.settings
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -18,18 +23,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -42,10 +52,13 @@ import com.rosetta.messenger.ui.chats.ChatsListViewModel
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Collapsing header constants
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
@@ -116,28 +129,167 @@ fun OtherProfileScreen(
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) }
}
// ═══════════════════════════════════════════════════════════════
// TELEGRAM-STYLE AVATAR EXPANSION (Drop/Blob effect)
// При свайпе вниз от верха списка - аватарка расширяется
// Порог snap = 33% (как в Telegram: expandProgress >= 0.33f)
// ═══════════════════════════════════════════════════════════════
var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
val snapThreshold = maxOverscroll * 0.33f // Telegram: 33%
// Track dragging state
var isDragging by remember { mutableStateOf(false) }
// isPulledDown = зафиксировано в раскрытом состоянии (как Telegram)
var isPulledDown by remember { mutableStateOf(false) }
// Velocity для учёта скорости свайпа
var lastVelocity by remember { mutableFloatStateOf(0f) }
// Haptic feedback
val hapticFeedback = LocalHapticFeedback.current
var hasTriggeredExpandHaptic by remember { mutableStateOf(false) }
// Проверяем наличие аватара у пользователя
val avatars by
avatarRepository?.getAvatars(user.publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val hasAvatar = avatars.isNotEmpty()
// ═══════════════════════════════════════════════════════════════
// SNAP ANIMATION - как Telegram's expandAnimator
// При отпускании пальца: snap к 0 или к max в зависимости от порога
// ═══════════════════════════════════════════════════════════════
val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем
isPulledDown -> maxOverscroll // После snap - держим раскрытым
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
else -> 0f // Не дотянули - snap обратно
}
// Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
val snapDuration = if (targetOverscroll == maxOverscroll) {
((1f - currentProgress) * 150).toInt().coerceIn(50, 150)
} else {
(currentProgress * 150).toInt().coerceIn(50, 150)
}
val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll,
animationSpec = tween(
durationMillis = if (isDragging) 0 else snapDuration,
easing = LinearOutSlowInEasing
),
label = "overscroll"
)
// ExpansionProgress для передачи в overlay
val expansionProgress = when {
collapseProgress > 0.1f -> 0f // Не расширяем при collapse
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
}
// Haptic при достижении порога (как Telegram)
LaunchedEffect(expansionProgress) {
if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredExpandHaptic = true
} else if (expansionProgress < 0.2f) {
hasTriggeredExpandHaptic = false
}
}
// DEBUG LOGS
Log.d("OtherProfileScroll", "expansionProgress=$expansionProgress, isPulledDown=$isPulledDown, isDragging=$isDragging")
// ═══════════════════════════════════════════════════════════════
// NESTED SCROLL - Telegram style with overscroll support
// ═══════════════════════════════════════════════════════════════
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = scrollOffset - delta
val consumed =
when {
delta < 0 && scrollOffset < maxScrollOffset -> {
val consumed =
(newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset)
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset)
-consumed
}
delta > 0 && scrollOffset > 0 -> {
val consumed =
scrollOffset - newOffset.coerceIn(0f, maxScrollOffset)
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset)
consumed
}
else -> 0f
isDragging = true
// Тянем вверх (delta < 0)
if (delta < 0) {
// Сначала убираем overscroll
if (overscrollOffset > 0) {
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset
// Сбрасываем isPulledDown если вышли из expanded
if (overscrollOffset < maxOverscroll * 0.5f) {
isPulledDown = false
}
return Offset(0f, consumed)
return Offset(0f, -consumed)
}
// Затем коллапсируем header
if (scrollOffset < maxScrollOffset) {
val newScrollOffset = (scrollOffset - delta).coerceIn(0f, maxScrollOffset)
val consumed = newScrollOffset - scrollOffset
scrollOffset = newScrollOffset
return Offset(0f, -consumed)
}
}
// Тянем вниз (delta > 0) - раскрываем header
if (delta > 0 && scrollOffset > 0) {
val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f)
val consumed = scrollOffset - newScrollOffset
scrollOffset = newScrollOffset
return Offset(0f, consumed)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Overscroll при свайпе вниз от верха
if (available.y > 0 && scrollOffset == 0f) {
// Telegram: сопротивление если ещё не isPulledDown
val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
return Offset(0f, available.y)
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
lastVelocity = available.y
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false
// Telegram: snap логика с учётом velocity
val velocityThreshold = 1000f
when {
overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> {
// Snap to expanded
isPulledDown = true
}
lastVelocity < -velocityThreshold && overscrollOffset > 0 -> {
// Fast swipe up - snap to collapsed
isPulledDown = false
}
else -> {
// Normal case - snap based on threshold
isPulledDown = overscrollOffset > snapThreshold
}
}
return Velocity.Zero
}
}
}
@@ -196,7 +348,7 @@ fun OtherProfileScreen(
}
// ═══════════════════════════════════════════════════════════
// 🎨 COLLAPSING HEADER
// 🎨 COLLAPSING HEADER with METABALL EFFECT
// ═══════════════════════════════════════════════════════════
CollapsingOtherProfileHeader(
name = user.title.ifEmpty { "Unknown User" },
@@ -207,6 +359,8 @@ fun OtherProfileScreen(
lastSeen = lastSeen,
avatarColors = avatarColors,
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
hasAvatar = hasAvatar,
onBack = onBack,
isDarkTheme = isDarkTheme,
showAvatarMenu = showAvatarMenu,
@@ -239,18 +393,20 @@ fun OtherProfileScreen(
}
}
// ═══════════════════════════════════════════════════════════
// 🎯 COLLAPSING HEADER FOR OTHER PROFILE
// 🎯 COLLAPSING HEADER FOR OTHER PROFILE with METABALL EFFECT
// ═══════════════════════════════════════════════════════════
@Composable
private fun CollapsingOtherProfileHeader(
name: String,
username: String,
@Suppress("UNUSED_PARAMETER") username: String,
publicKey: String,
verified: Int,
isOnline: Boolean,
lastSeen: Long,
avatarColors: AvatarColors,
collapseProgress: Float,
expansionProgress: Float,
hasAvatar: Boolean,
onBack: () -> Unit,
isDarkTheme: Boolean,
showAvatarMenu: Boolean,
@@ -261,34 +417,47 @@ private fun CollapsingOtherProfileHeader(
onClearChat: () -> Unit
) {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val expandedHeight = EXPANDED_HEADER_HEIGHT_OTHER + statusBarHeight
val collapsedHeight = COLLAPSED_HEADER_HEIGHT_OTHER + statusBarHeight
// Header height меняется только при collapse, НЕ при overscroll
val headerHeight =
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
// Avatar animation
val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED_OTHER) / 2
val avatarStartY = statusBarHeight + 32.dp
val avatarEndY = statusBarHeight - 60.dp
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress)
val avatarSize =
androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED_OTHER, 0.dp, collapseProgress)
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
// Avatar font size for placeholder
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
val hapticFeedback = LocalHapticFeedback.current
var hasTriggeredHaptic by remember { mutableStateOf(false) }
LaunchedEffect(expansionProgress, hasAvatar) {
if (hasAvatar && expansionProgress >= 0.95f && !hasTriggeredHaptic) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredHaptic = true
} else if (expansionProgress < 0.5f) {
hasTriggeredHaptic = false
}
}
// Text animation - always centered
val textExpandedY = statusBarHeight + 32.dp + AVATAR_SIZE_EXPANDED_OTHER + 48.dp
val textDefaultY = expandedHeight - 48.dp
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2
val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseProgress)
val textY = androidx.compose.ui.unit.lerp(textDefaultY, textCollapsedY, collapseProgress)
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
// Определяем цвет текста на основе фона
val textColor by remember(hasAvatar, avatarColors) {
derivedStateOf {
if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White
}
}
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND
@@ -301,6 +470,43 @@ private fun CollapsingOtherProfileHeader(
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation
// При скролле вверх аватарка "сливается" с Dynamic Island
// При свайпе вниз - расширяется на весь экран
// ═══════════════════════════════════════════════════════════
ProfileMetaballEffect(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColors.backgroundColor,
modifier = Modifier.fillMaxSize()
) {
// Содержимое аватара
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
// Placeholder без аватарки
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
}
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════
@@ -315,7 +521,7 @@ private fun CollapsingOtherProfileHeader(
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
tint = if (isDarkTheme) Color.White else Color(0xFF007AFF),
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -336,9 +542,7 @@ private fun CollapsingOtherProfileHeader(
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Profile menu",
tint =
if (isColorLight(avatarColors.backgroundColor)) Color.Black
else Color.White,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -361,36 +565,7 @@ private fun CollapsingOtherProfileHeader(
}
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
Box(
modifier =
Modifier.offset(
x =
avatarCenterX +
(AVATAR_SIZE_EXPANDED_OTHER -
avatarSize) / 2,
y = avatarY
)
.size(avatarSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f))
.padding(2.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = isDarkTheme
)
}
}
// ═══════════════════════════════════════════════════════════
// 📝 TEXT BLOCK - Name + Verified + Online, always centered
// 📝 TEXT BLOCK - Name + Verified + Online
// ═══════════════════════════════════════════════════════════
Column(
modifier =
@@ -416,11 +591,10 @@ private fun CollapsingOtherProfileHeader(
text = name,
fontSize = nameFontSize,
fontWeight = FontWeight.SemiBold,
color =
if (isColorLight(avatarColors.backgroundColor)) Color.Black
else Color.White,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center
)
@@ -440,15 +614,70 @@ private fun CollapsingOtherProfileHeader(
if (isOnline) {
Color(0xFF4CAF50)
} else {
if (isColorLight(avatarColors.backgroundColor))
Color.Black.copy(alpha = 0.6f)
else Color.White.copy(alpha = 0.6f)
textColor.copy(alpha = 0.6f)
}
)
}
}
}
// ═════════════════════════════════════════════════════════════
// 🖼 FULL SIZE AVATAR FOR OTHER PROFILE - Fills entire container
// ═════════════════════════════════════════════════════════════
@Composable
private fun OtherProfileFullSizeAvatar(
publicKey: String,
avatarRepository: AvatarRepository?,
isDarkTheme: Boolean = false
) {
val avatars by
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Сохраняем bitmap в remember чтобы не мигал при recomposition
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(avatars) {
if (avatars.isNotEmpty()) {
val newBitmap = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
bitmap = newBitmap
isLoading = false
} else {
isLoading = false
}
}
when {
bitmap != null -> {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
!isLoading -> {
// Placeholder только когда точно нет аватарки
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = publicKey.take(2).uppercase(),
color = avatarColors.textColor,
fontSize = 80.sp,
fontWeight = FontWeight.Bold
)
}
}
// Пока isLoading=true - ничего не показываем (прозрачно)
}
}
// ═══════════════════════════════════════════════════════════
// 🚫 BLOCK/UNBLOCK ITEM
// ═══════════════════════════════════════════════════════════