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

@@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
/**
* 🚀 Оптимизированный кэш эмодзи с предзагрузкой
@@ -47,21 +46,20 @@ object OptimizedEmojiCache {
scope.launch {
try {
val duration = measureTimeMillis {
// Шаг 1: Загружаем список эмодзи (быстро)
loadEmojiList(context)
loadProgress = 0.3f
// Шаг 2: Группируем по категориям (средне)
groupEmojisByCategories()
loadProgress = 0.6f
// Шаг 3: Предзагружаем популярные изображения (медленно, но в фоне)
preloadPopularEmojis(context)
loadProgress = 1f
}
// Шаг 1: Загружаем список эмодзи (быстро)
loadEmojiList(context)
loadProgress = 0.3f
// Шаг 2: Группируем по категориям (средне)
groupEmojisByCategories()
loadProgress = 0.6f
// 🔥 Сразу отмечаем как загруженный - предзагрузка идёт в фоне
isLoaded = true
// Шаг 3: Предзагружаем популярные изображения (в фоне, не блокирует UI)
preloadPopularEmojis(context)
loadProgress = 1f
isPreloading = false
} catch (e: Exception) {
allEmojis = emptyList()

View File

@@ -19,10 +19,10 @@ import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
@@ -38,25 +38,16 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
/**
* 🚀 ОПТИМИЗИРОВАННЫЙ EMOJI PICKER
*
* Ключевые оптимизации:
* 1. Предзагрузка популярных эмодзи при старте приложения
* 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout)
* 3. Hardware layer для анимаций
* 4. Минимум recomposition (derivedStateOf, remember keys)
* 5. Coil оптимизация (hardware acceleration, size limits)
* 6. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
* 7. keyboardDuration для синхронизации с системной клавиатурой
* 8. Анимация управляется внешним AnimatedKeyboardTransition
*
* @param isVisible Видимость панели (для внутренней логики)
* @param isDarkTheme Темная/светлая тема
* @param onEmojiSelected Callback при выборе эмодзи
* @param onClose Callback при закрытии
* @param modifier Модификатор
* 🚀 ULTRA-ОПТИМИЗИРОВАННЫЙ EMOJI PICKER
*
* Ключевые оптимизации v2:
* 1. ZERO LaunchedEffect в EmojiButton - никаких корутин на каждый эмодзи
* 2. Нет анимаций scale - убрали spring animations для каждой кнопки
* 3. Нет interactionSource tracking - убрали collect для каждой кнопки
* 4. Stable composables - используем @Stable для избежания recomposition
* 5. Оптимизированный LazyGrid с prefetch
* 6. Minimal modifier chain - меньше лямбд, меньше allocations
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun OptimizedEmojiPicker(
isVisible: Boolean,
@@ -65,15 +56,9 @@ fun OptimizedEmojiPicker(
onClose: () -> Unit = {},
modifier: Modifier = Modifier
) {
// 🔥 Используем сохранённую высоту клавиатуры (как в Telegram)
val savedKeyboardHeight = rememberSavedKeyboardHeight()
// 🔥 Логирование изменений видимости
LaunchedEffect(isVisible) {
}
// 🔥 Рендерим контент напрямую без AnimatedVisibility
// Анимация теперь управляется AnimatedKeyboardTransition
// 🔥 Рендерим напрямую без лишних обёрток
EmojiPickerContent(
isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected,
@@ -83,7 +68,13 @@ fun OptimizedEmojiPicker(
}
/**
* Контент emoji picker'а
* 🔥 Stable wrapper для callback чтобы избежать recomposition
*/
@Stable
private class StableCallback(val onClick: (String) -> Unit)
/**
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ
*/
@Composable
private fun EmojiPickerContent(
@@ -96,25 +87,19 @@ private fun EmojiPickerContent(
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
// 🚀 Отложенный рендеринг - даём анимации начаться без фриза
var shouldRenderContent by remember { mutableStateOf(false) }
// 🔥 Wrap callback в stable class для избежания recomposition
val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) }
// 🚀 Загружаем эмодзи ОДИН раз при первом рендере
LaunchedEffect(Unit) {
// Ждём 1 кадр чтобы анимация началась плавно
kotlinx.coroutines.delay(16) // ~1 frame at 60fps
shouldRenderContent = true
// Загружаем эмодзи если еще не загружены
if (!OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.preload(context)
} else {
}
}
// 🚀 Используем derivedStateOf чтобы избежать лишних recomposition
val displayedEmojis by remember {
// 🚀 derivedStateOf для минимизации recomposition
val displayedEmojis by remember(selectedCategory) {
derivedStateOf {
if (OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key)
@@ -123,71 +108,61 @@ private fun EmojiPickerContent(
}
}
}
// 🚀 При смене категории плавно скроллим наверх
// 🚀 Scroll to top при смене категории - БЕЗ анимации для скорости
LaunchedEffect(selectedCategory) {
if (displayedEmojis.isNotEmpty()) {
scope.launch {
gridState.animateScrollToItem(0)
}
gridState.scrollToItem(0) // 🔥 scrollToItem вместо animateScrollToItem
}
}
// 🎨 Цвета темы
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
val categoryBarBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
// 🎨 Цвета темы - computed один раз
val panelBackground = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
}
val categoryBarBackground = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
}
val dividerColor = remember(isDarkTheme) {
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
}
Column(
modifier = modifier
.fillMaxWidth()
.height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram)
.height(keyboardHeight)
.background(panelBackground)
) {
// 🔥 Показываем пустую панель пока не готово
if (!shouldRenderContent) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = PrimaryBlue,
strokeWidth = 2.dp
)
}
} else {
// ============ КАТЕГОРИИ ============
CategoryBar(
categories = EMOJI_CATEGORIES,
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it },
isDarkTheme = isDarkTheme,
backgroundColor = categoryBarBackground
)
// ============ РАЗДЕЛИТЕЛЬ ============
Divider(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp),
color = dividerColor
)
// ============ СЕТКА ЭМОДЗИ ============
EmojiGrid(
isLoaded = OptimizedEmojiCache.isLoaded,
emojis = displayedEmojis,
gridState = gridState,
onEmojiSelected = onEmojiSelected,
isDarkTheme = isDarkTheme
)
}
// ============ КАТЕГОРИИ ============
CategoryBar(
categories = EMOJI_CATEGORIES,
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it },
isDarkTheme = isDarkTheme,
backgroundColor = categoryBarBackground
)
// ============ РАЗДЕЛИТЕЛЬ ============
Divider(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp),
color = dividerColor
)
// ============ СЕТКА ЭМОДЗИ ============
UltraOptimizedEmojiGrid(
isLoaded = OptimizedEmojiCache.isLoaded,
emojis = displayedEmojis,
gridState = gridState,
onEmojiSelected = stableCallback,
isDarkTheme = isDarkTheme
)
}
}
/**
* Горизонтальная полоса категорий
* Горизонтальная полоса категорий - ОПТИМИЗИРОВАННАЯ
*/
@Composable
private fun CategoryBar(
@@ -197,6 +172,9 @@ private fun CategoryBar(
isDarkTheme: Boolean,
backgroundColor: Color
) {
// 🔥 Запоминаем interactionSource один раз для всего LazyRow
val interactionSource = remember { MutableInteractionSource() }
LazyRow(
modifier = Modifier
.fillMaxWidth()
@@ -210,7 +188,8 @@ private fun CategoryBar(
items = categories,
key = { it.key }
) { category ->
CategoryButton(
// 🔥 Минимальная CategoryButton без анимаций
SimpleCategoryButton(
category = category,
isSelected = selectedCategory == category,
onClick = { onCategorySelected(category) },
@@ -221,29 +200,21 @@ private fun CategoryBar(
}
/**
* Кнопка категории
* 🔥 УПРОЩЁННАЯ кнопка категории - без анимаций
*/
@Composable
private fun CategoryButton(
private fun SimpleCategoryButton(
category: EmojiCategory,
isSelected: Boolean,
onClick: () -> Unit,
isDarkTheme: Boolean
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent,
animationSpec = tween(150),
label = "categoryBackground"
)
val iconTint by animateColorAsState(
targetValue = if (isSelected) PrimaryBlue
else if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f),
animationSpec = tween(150),
label = "categoryIcon"
)
// 🔥 Статичные цвета - нет анимации!
val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
val iconTint = if (isSelected) PrimaryBlue
else if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f)
Box(
modifier = Modifier
.size(44.dp)
@@ -251,7 +222,7 @@ private fun CategoryButton(
.background(backgroundColor)
.clickable(
onClick = onClick,
indication = null, // 🚀 Убираем ripple для производительности
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
@@ -266,22 +237,20 @@ private fun CategoryButton(
}
/**
* Сетка эмодзи с LazyGrid
* 🔥 ULTRA-оптимизированная сетка эмодзи
*/
@Composable
private fun EmojiGrid(
private fun UltraOptimizedEmojiGrid(
isLoaded: Boolean,
emojis: List<String>,
gridState: androidx.compose.foundation.lazy.grid.LazyGridState,
onEmojiSelected: (String) -> Unit,
onEmojiSelected: StableCallback,
isDarkTheme: Boolean
) {
when {
!isLoaded -> {
// Loading state
Box(
modifier = Modifier
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
@@ -292,10 +261,8 @@ private fun EmojiGrid(
}
}
emojis.isEmpty() -> {
// Empty state
Box(
modifier = Modifier
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
@@ -306,115 +273,74 @@ private fun EmojiGrid(
}
}
else -> {
// 🚀 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid
// 🔥 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(8),
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
horizontal = 8.dp,
vertical = 8.dp
),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
userScrollEnabled = true,
// 🚀 Оптимизация: рендерим +2 строки за пределами видимой области
// для плавной прокрутки без белых мерцаний
content = {
items(
items = emojis,
key = { emoji -> emoji }, // 🔥 Важно для stable composition
contentType = { "emoji" }
) { unified ->
OptimizedEmojiButton(
unified = unified,
onClick = { emoji -> onEmojiSelected(emoji) }
)
}
}
)
}
}
}
/**
* 🚀 Оптимизированная кнопка эмодзи
*/
@Composable
private fun OptimizedEmojiButton(
unified: String,
onClick: (String) -> Unit
) {
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
// 🚀 Простая scale анимация без сложных эффектов
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.85f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "emojiScale"
)
// 🚀 Оптимизированный ImageRequest с кэшированием
val imageRequest = remember(unified) {
ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${unified.lowercase()}.png")
.crossfade(false) // 🔥 Выключаем crossfade для производительности
.size(64) // 🔥 Ограничиваем размер для экономии памяти
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.allowHardware(true) // 🔥 Hardware acceleration
.memoryCacheKey("emoji_$unified")
.diskCacheKey("emoji_$unified")
.build()
}
Box(
modifier = Modifier
.aspectRatio(1f)
.scale(scale)
.clip(RoundedCornerShape(8.dp))
.clickable(
interactionSource = interactionSource,
indication = null, // 🚀 Убираем ripple
onClickLabel = "Select emoji"
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// 🔥 Отправляем эмодзи в формате :emoji_code: как в десктопе
onClick(":emoji_$unified:")
},
contentAlignment = Alignment.Center
) {
// 🚀 AsyncImage с Coil (оптимизирован)
AsyncImage(
model = imageRequest,
contentDescription = null,
modifier = Modifier
.size(32.dp)
.graphicsLayer {
// Hardware layer для лучшей производительности
this.alpha = 1f
},
contentScale = ContentScale.Fit
)
}
// Track press state для scale анимации
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is androidx.compose.foundation.interaction.PressInteraction.Press -> {
isPressed = true
}
is androidx.compose.foundation.interaction.PressInteraction.Release,
is androidx.compose.foundation.interaction.PressInteraction.Cancel -> {
isPressed = false
items(
items = emojis,
key = { emoji -> emoji },
contentType = { "emoji" }
) { unified ->
// 🔥 ULTRA-лёгкая кнопка эмодзи
UltraLightEmojiButton(
unified = unified,
onClick = onEmojiSelected.onClick
)
}
}
}
}
}
/**
* 🔥 ULTRA-ЛЁГКАЯ кнопка эмодзи
*
* Оптимизации:
* - Нет LaunchedEffect
* - Нет анимаций
* - Нет interactionSource tracking
* - Минимальный modifier chain
* - Предзакэшированный ImageRequest
*/
@Composable
private fun UltraLightEmojiButton(
unified: String,
onClick: (String) -> Unit
) {
val context = LocalContext.current
// 🔥 Один remember для ImageRequest - это единственный "тяжёлый" объект
val imageRequest = remember(unified) {
ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${unified.lowercase()}.png")
.crossfade(false) // Нет анимации
.size(48) // Меньше размер = быстрее
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.allowHardware(true)
.memoryCacheKey("emoji_$unified")
.build()
}
// 🔥 Минимальный Box без лишних модификаторов
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(6.dp))
.clickable { onClick(":emoji_$unified:") },
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageRequest,
contentDescription = null,
modifier = Modifier.size(28.dp),
contentScale = ContentScale.Fit
)
}
}