feat: implement Telegram-style avatar expansion with smooth scaling and snap animation in ProfileScreen

This commit is contained in:
k1ngsterr1
2026-02-01 15:10:21 +05:00
parent 832227cf1c
commit 6d15a34512
2 changed files with 266 additions and 130 deletions

View File

@@ -528,48 +528,94 @@ fun ProfileMetaballOverlay(
} }
} // END if (showMetaballLayer) } // END if (showMetaballLayer)
// LAYER 2: Actual avatar content - with blur and shape transition like Telegram // ═══════════════════════════════════════════════════════════════
// LAYER 2: Avatar content - TELEGRAM STYLE EXPANSION
// При expansion: плавно растёт через SCALE (не размер!)
// Это даёт smooth анимацию как в Telegram
// ═══════════════════════════════════════════════════════════════
if (avatarState.showBlob) { if (avatarState.showBlob) {
// При раскрытии - занимаем всю ширину экрана и всю высоту header'а // Базовый размер аватарки
val isExpanded = expansionProgress > 0f val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val avatarWidthDp = with(density) { val baseSizePx = with(density) { baseSizeDp.toPx() }
if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp()
}
val avatarHeightDp = with(density) {
if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp()
}
val avatarOffsetX = with(density) {
if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp()
}
val avatarOffsetY = with(density) {
if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp()
}
// Corner radius from state - transitions from circle to rounded rect // Позиция центра
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() } val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY
Box( // При expansion > 0: используем SCALE для плавного роста
modifier = Modifier // При collapse: используем обычный размер
.offset(x = avatarOffsetX, y = avatarOffsetY) val isExpanding = expansionProgress > 0f
.width(avatarWidthDp)
.height(avatarHeightDp) if (isExpanding) {
// Use dynamic corner radius - circle → rounded rect transition // ═══════════════════════════════════════════════════════════
.clip(RoundedCornerShape(cornerRadiusDp)) // EXPANSION MODE - плавный рост через scale
.graphicsLayer { // ═══════════════════════════════════════════════════════════
alpha = avatarState.opacity val targetWidth = screenWidthPx
// Apply blur to avatar when near notch (like Telegram) val targetHeight = headerHeightPx
// Telegram: blurRadius = 2 + (1 - fraction) * 20
if (avatarState.blurRadius > 0.5f) { // Scale от базового размера до целевого
renderEffect = RenderEffect.createBlurEffect( val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress)
avatarState.blurRadius, val scaleY = lerpFloat(1f, targetHeight / baseSizePx, expansionProgress)
avatarState.blurRadius,
Shader.TileMode.DECAL // Центр смещается к центру экрана/header
).asComposeRenderEffect() val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
// Offset для позиционирования (учитывая что scale от центра)
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
// Corner radius плавно уменьшается к 0 (квадрат)
// Но только после 70% expansion для эффекта как в Telegram
val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f)
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress)
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
this.scaleX = scaleX
this.scaleY = scaleY
alpha = avatarState.opacity
} }
}, .clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
content = avatarContent content = avatarContent
) )
} else {
// ═══════════════════════════════════════════════════════════
// COLLAPSE/NORMAL MODE - обычный размер для капли
// ═══════════════════════════════════════════════════════════
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
if (avatarState.blurRadius > 0.5f) {
renderEffect = RenderEffect.createBlurEffect(
avatarState.blurRadius,
avatarState.blurRadius,
Shader.TileMode.DECAL
).asComposeRenderEffect()
}
},
contentAlignment = Alignment.Center,
content = avatarContent
)
}
} }
} }
} }
@@ -635,34 +681,62 @@ fun ProfileMetaballOverlayCompat(
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
if (avatarState.showBlob) { if (avatarState.showBlob) {
// При раскрытии - занимаем всю ширину экрана и всю высоту header'а val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val isExpanded = expansionProgress > 0f val baseSizePx = with(density) { baseSizeDp.toPx() }
val avatarWidthDp = with(density) { val avatarCenterX = avatarState.centerX
if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp() val avatarCenterY = avatarState.centerY
} val isExpanding = expansionProgress > 0f
val avatarHeightDp = with(density) {
if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp()
}
val avatarOffsetX = with(density) {
if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp()
}
val avatarOffsetY = with(density) {
if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp()
}
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box( if (isExpanding) {
modifier = Modifier // EXPANSION MODE - scale animation
.offset(x = avatarOffsetX, y = avatarOffsetY) val targetWidth = screenWidthPx
.width(avatarWidthDp) val targetHeight = headerHeightPx
.height(avatarHeightDp) val scaleX = lerpFloat(1f, targetWidth / baseSizePx, expansionProgress)
.clip(RoundedCornerShape(cornerRadiusDp)) val scaleY = lerpFloat(1f, targetHeight / baseSizePx, expansionProgress)
.graphicsLayer { val targetCenterX = screenWidthPx / 2f
alpha = avatarState.opacity val targetCenterY = headerHeightPx / 2f
}, val currentCenterX = lerpFloat(avatarCenterX, targetCenterX, expansionProgress)
contentAlignment = Alignment.Center, val currentCenterY = lerpFloat(avatarCenterY, targetCenterY, expansionProgress)
content = avatarContent val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
) val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
val squareProgress = ((expansionProgress - 0.7f) / 0.3f).coerceIn(0f, 1f)
val cornerRadiusPx = lerpFloat(baseSizePx / 2f, 0f, squareProgress)
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
this.scaleX = scaleX
this.scaleY = scaleY
alpha = avatarState.opacity
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
} else {
// COLLAPSE/NORMAL MODE
val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() }
val avatarOffsetX = with(density) { (avatarCenterX - avatarState.radius).toDp() }
val avatarOffsetY = with(density) { (avatarCenterY - avatarState.radius).toDp() }
val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() }
Box(
modifier = Modifier
.offset(x = avatarOffsetX, y = avatarOffsetY)
.width(avatarSizeDp)
.height(avatarSizeDp)
.clip(RoundedCornerShape(cornerRadiusDp))
.graphicsLayer {
alpha = avatarState.opacity
},
contentAlignment = Alignment.Center,
content = avatarContent
)
}
} }
} }
} }

View File

@@ -305,65 +305,95 @@ fun ProfileScreen(
(expandedHeightPx - scrollOffset).coerceAtLeast(collapsedHeightPx).toDp() (expandedHeightPx - scrollOffset).coerceAtLeast(collapsedHeightPx).toDp()
} }
// Track overscroll offset for avatar expansion (скролл вниз при достижении верха) // ═══════════════════════════════════════════════════════════════
// Telegram: когда extraHeight > headerExtraHeight - это isPulledDown // TELEGRAM-STYLE AVATAR EXPANSION
// При свайпе вниз от верха списка - аватарка расширяется
// Порог snap = 33% (как в Telegram: expandProgress >= 0.33f)
// ═══════════════════════════════════════════════════════════════
var overscrollOffset by remember { mutableFloatStateOf(0f) } var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 200.dp.toPx() } val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
val snapThreshold = maxOverscroll * 0.33f // Telegram: expandProgress >= 0.33f val snapThreshold = maxOverscroll * 0.33f // Telegram: 33%
// Track if user is currently dragging // Track dragging state
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
// Track if fully expanded (snapped to full) // isPulledDown = зафиксировано в раскрытом состоянии (как Telegram)
var isFullyExpanded by remember { mutableStateOf(false) } 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) }
// Smooth snap animation - только когда отпустили палец // ═══════════════════════════════════════════════════════════════
// Telegram использует CubicBezierInterpolator.EASE_BOTH для snap // SNAP ANIMATION - как Telegram's expandAnimator
// При отпускании пальца: snap к 0 или к max в зависимости от порога
// ═══════════════════════════════════════════════════════════════
val targetOverscroll = when { val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем
isFullyExpanded -> maxOverscroll // Зафиксировано в раскрытом isPulledDown -> maxOverscroll // После snap - держим раскрытым
overscrollOffset > snapThreshold -> maxOverscroll // Snap к раскрытому overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
else -> 0f // Snap к закрытому 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) * 250).toInt().coerceIn(100, 300)
} else {
(currentProgress * 250).toInt().coerceIn(100, 300)
} }
val animatedOverscroll by animateFloatAsState( val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll, targetValue = targetOverscroll,
animationSpec = tween( animationSpec = tween(
durationMillis = if (isDragging) 0 else 250, // Мгновенно при drag, плавно при snap durationMillis = if (isDragging) 0 else snapDuration,
easing = FastOutSlowInEasing // Как Telegram's EASE_BOTH easing = FastOutSlowInEasing // Telegram: CubicBezierInterpolator.EASE_BOTH
), ),
label = "overscroll" label = "overscroll"
) )
// Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт // ExpansionProgress для передачи в overlay
// Telegram: expandProgress = (extraHeight - headerExtraHeight) / (listWidth - actionBarHeight - headerOnlyExtraHeight)
val expansionProgress = when { val expansionProgress = when {
collapseProgress > 0.1f -> 0f // Не расширяем если header коллапсирован collapseProgress > 0.1f -> 0f // Не расширяем при collapse
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) // Напрямую при drag isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // Анимированно при snap 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 // DEBUG LOGS
Log.d("ProfileScroll", "scrollOffset=$scrollOffset, collapseProgress=$collapseProgress") Log.d("ProfileScroll", "expansionProgress=$expansionProgress, isPulledDown=$isPulledDown, isDragging=$isDragging")
Log.d("ProfileScroll", "overscrollOffset=$overscrollOffset, expansionProgress=$expansionProgress, isDragging=$isDragging")
// Nested scroll connection для collapsing + overscroll // ═══════════════════════════════════════════════════════════════
// NESTED SCROLL - Telegram style
// ═══════════════════════════════════════════════════════════════
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y val delta = available.y
isDragging = true isDragging = true
// Если тянем вверх (delta < 0) // Тянем вверх (delta < 0)
if (delta < 0) { if (delta < 0) {
// Сначала убираем overscroll если есть // Сначала убираем overscroll
if (overscrollOffset > 0) { if (overscrollOffset > 0) {
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
val consumed = overscrollOffset - newOffset val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset overscrollOffset = newOffset
// Если вышли из fully expanded - сбросить флаг // Сбрасываем isPulledDown если вышли из expanded
if (overscrollOffset < maxOverscroll * 0.9f) { if (overscrollOffset < maxOverscroll * 0.5f) {
isFullyExpanded = false isPulledDown = false
} }
return Offset(0f, -consumed) return Offset(0f, -consumed)
} }
@@ -376,7 +406,7 @@ fun ProfileScreen(
} }
} }
// Если тянем вниз (delta > 0) и header коллапсирован - сначала раскрываем // Тянем вниз (delta > 0) - раскрываем header
if (delta > 0 && scrollOffset > 0) { if (delta > 0 && scrollOffset > 0) {
val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f) val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f)
val consumed = scrollOffset - newScrollOffset val consumed = scrollOffset - newScrollOffset
@@ -392,10 +422,10 @@ fun ProfileScreen(
available: Offset, available: Offset,
source: NestedScrollSource source: NestedScrollSource
): Offset { ): Offset {
// Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll // Overscroll при свайпе вниз от верха
if (available.y > 0 && scrollOffset == 0f) { if (available.y > 0 && scrollOffset == 0f) {
// Telegram: if (!isPulledDown) dy /= 2 // Telegram: сопротивление если ещё не isPulledDown
val resistance = if (isFullyExpanded) 1f else 0.5f val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll) overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
return Offset(0f, available.y) return Offset(0f, available.y)
@@ -403,12 +433,34 @@ fun ProfileScreen(
return Offset.Zero 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 { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false isDragging = false
// Если перешли порог - зафиксировать как fully expanded
if (overscrollOffset > snapThreshold) { // Telegram: snap логика с учётом velocity
isFullyExpanded = true // Если velocity > 1000 и тянем вниз - snap to expanded даже если < 33%
// Если velocity < -1000 и тянем вверх - snap to collapsed даже если > 33%
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 return Velocity.Zero
} }
} }
@@ -997,7 +1049,7 @@ private fun CollapsingProfileHeader(
} }
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
// <EFBFBD> FULL SIZE AVATAR - Fills entire container (for expanded state) // 🖼 FULL SIZE AVATAR - Fills entire container (for expanded state)
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
@Composable @Composable
private fun FullSizeAvatar( private fun FullSizeAvatar(
@@ -1009,41 +1061,51 @@ private fun FullSizeAvatar(
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
var bitmap by remember(avatars) { mutableStateOf<android.graphics.Bitmap?>(null) } // Сохраняем bitmap в remember чтобы не мигал при recomposition
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(avatars) { LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) {
if (avatars.isNotEmpty()) { val newBitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(
com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap( avatars.first().base64Data
avatars.first().base64Data )
) }
} bitmap = newBitmap
} else { isLoading = false
null } else {
} isLoading = false
}
} }
if (bitmap != null) { // Показываем картинку если есть, иначе placeholder
Image( // НО не показываем placeholder пока идёт загрузка (чтобы не мигало)
bitmap = bitmap!!.asImageBitmap(), when {
contentDescription = "Avatar", bitmap != null -> {
modifier = Modifier.fillMaxSize(), Image(
contentScale = ContentScale.Crop bitmap = bitmap!!.asImageBitmap(),
) contentDescription = "Avatar",
} else { modifier = Modifier.fillMaxSize(),
val avatarColors = getAvatarColor(publicKey, isDarkTheme) contentScale = ContentScale.Crop
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 -> {
// 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 - ничего не показываем (прозрачно)
} }
} }