feat: implement Telegram-style avatar expansion with smooth scaling and snap animation in ProfileScreen
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 - ничего не показываем (прозрачно)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user