feat: Optimize AppleEmojiPicker with caching and enhance UI with Liquid Glass style

This commit is contained in:
k1ngsterr1
2026-01-12 02:09:56 +05:00
parent 5f348f329e
commit 7bac22850e
2 changed files with 214 additions and 62 deletions

View File

@@ -56,8 +56,11 @@ import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import android.view.inputmethod.InputMethodManager
import android.content.Context
import android.graphics.Rect
import android.view.ViewTreeObserver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalDensity
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.delay
@@ -284,14 +287,41 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() }
// 🔥 Cleanup при выходе из экрана
DisposableEffect(Unit) {
// 🔥 Ручное отслеживание высоты клавиатуры
val view = LocalView.current
val density = LocalDensity.current
var keyboardHeight by remember { mutableStateOf(0.dp) }
DisposableEffect(view) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keyboardHeightPx = screenHeight - rect.bottom
// Учитываем navigation bar (если клавиатура меньше 150px - это не клавиатура)
val actualKeyboardHeight = if (keyboardHeightPx > 150) keyboardHeightPx else 0
keyboardHeight = with(density) { actualKeyboardHeight.toDp() }
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
focusManager.clearFocus()
keyboardController?.hide()
}
}
// Автоматический скролл вниз при появлении клавиатуры
LaunchedEffect(keyboardHeight) {
if (keyboardHeight > 0.dp && messages.isNotEmpty()) {
delay(50)
listState.animateScrollToItem(0)
}
}
// Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) {
@@ -549,15 +579,21 @@ fun ChatDetailScreen(
},
containerColor = Color.Transparent
) { paddingValues ->
// 🔥 Column с imePadding - весь контент поднимается с клавиатурой
Column(
// 🔥 Box с фиксированным Input внизу и сообщениями выше
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding() // Контент поднимается над клавиатурой
) {
// Список сообщений - занимает всё доступное пространство
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Список сообщений - занимает всё пространство, с padding снизу для input и клавиатуры
val inputBarHeight = 60.dp
val bottomPadding = inputBarHeight + keyboardHeight
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding)
) {
if (messages.isEmpty()) {
// Пустое состояние
Column(
@@ -723,35 +759,42 @@ fun ChatDetailScreen(
)
}
}
}
} // Закрытие внутреннего Box для сообщений
// 🔥 INPUT BAR - внизу Column, автоматически над клавиатурой
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = {
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
viewModel.sendMessage()
// Скроллим к новому сообщению
scope.launch {
delay(100)
listState.animateScrollToItem(0)
delay(300) // Ждём завершения анимации
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor
)
// 🔥 INPUT BAR - фиксированный элемент внизу экрана
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = keyboardHeight) // Ручной padding над клавиатурой
) {
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = {
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
viewModel.sendMessage()
// Скроллим к новому сообщению
scope.launch {
delay(100)
listState.animateScrollToItem(0)
delay(300) // Ждём завершения анимации
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor
)
}
}
}
} // Закрытие Box с fade-in

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.ui.components
import android.content.Context
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
@@ -18,12 +20,15 @@ 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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -147,6 +152,7 @@ fun unifiedToEmoji(unified: String): String {
/**
* Кнопка эмодзи с PNG изображением и анимацией нажатия
* Оптимизирована с кэшированием
*/
@Composable
fun EmojiButton(
@@ -167,6 +173,19 @@ fun EmojiButton(
label = "emojiScale"
)
// Мемоизируем ImageRequest для оптимизации - используем assetFile fetcher
val imageRequest = remember(unified, context) {
ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${unified.lowercase()}.png")
.crossfade(false)
.size(64) // Задаём размер для оптимизации памяти
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCacheKey("emoji_${unified}")
.diskCacheKey("emoji_${unified}")
.build()
}
Box(
modifier = modifier
.aspectRatio(1f)
@@ -181,10 +200,7 @@ fun EmojiButton(
contentAlignment = Alignment.Center
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${unified.lowercase()}.png")
.crossfade(false)
.build(),
model = imageRequest,
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit
@@ -200,6 +216,7 @@ fun CategoryButton(
category: EmojiCategory,
isSelected: Boolean,
onClick: () -> Unit,
isDarkTheme: Boolean = true,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -215,13 +232,26 @@ fun CategoryButton(
label = "categoryScale"
)
// Мемоизируем ImageRequest с размером для оптимизации
val imageRequest = remember(category.label, context) {
ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${category.label.lowercase()}.png")
.crossfade(false)
.size(48)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCacheKey("category_${category.label}")
.diskCacheKey("category_${category.label}")
.build()
}
Box(
modifier = modifier
.size(40.dp)
.scale(scale)
.clip(CircleShape)
.background(
if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
if (isSelected) PrimaryBlue.copy(alpha = 0.25f) else Color.Transparent
)
.clickable(
interactionSource = interactionSource,
@@ -231,10 +261,7 @@ fun CategoryButton(
contentAlignment = Alignment.Center
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${category.label.lowercase()}.png")
.crossfade(false)
.build(),
model = imageRequest,
contentDescription = category.key,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit
@@ -243,7 +270,7 @@ fun CategoryButton(
}
/**
* Apple Emoji Picker Panel
* Apple Emoji Picker Panel - Liquid Glass стиль
*/
@Composable
fun AppleEmojiPickerPanel(
@@ -253,25 +280,102 @@ fun AppleEmojiPickerPanel(
modifier: Modifier = Modifier
) {
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
val gridState = rememberLazyGridState()
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF8F8FA)
val categoryBackground = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFFFFFFF)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
// Мемоизируем текущие эмодзи
val currentEmojis = remember(selectedCategory.key) {
EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList()
}
val currentEmojis = EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList()
// Сбрасываем скролл при смене категории
LaunchedEffect(selectedCategory) {
gridState.scrollToItem(0)
}
// Liquid Glass цвета
val glassBackground = if (isDarkTheme) {
Brush.verticalGradient(
colors = listOf(
Color(0xFF2D2D2F).copy(alpha = 0.95f),
Color(0xFF1C1C1E).copy(alpha = 0.98f)
)
)
} else {
Brush.verticalGradient(
colors = listOf(
Color(0xFFF2F2F7).copy(alpha = 0.96f),
Color(0xFFE5E5EA).copy(alpha = 0.98f)
)
)
}
val glassBorder = if (isDarkTheme) {
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.15f),
Color.White.copy(alpha = 0.05f)
)
)
} else {
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.9f),
Color.Black.copy(alpha = 0.05f)
)
)
}
val categoryBarBackground = if (isDarkTheme) Color(0xFF1A1A1C).copy(alpha = 0.9f)
else Color.White.copy(alpha = 0.85f)
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.06f)
Column(
modifier = modifier
.fillMaxWidth()
.height(300.dp)
.background(panelBackground)
.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
clip = false,
ambientColor = Color.Black.copy(alpha = 0.3f),
spotColor = Color.Black.copy(alpha = 0.3f)
)
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.background(glassBackground)
.border(
width = 1.dp,
brush = glassBorder,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
)
) {
// Категории сверху
// Ручка для свайпа (как в iOS)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(36.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.3f)
else Color.Black.copy(alpha = 0.2f)
)
)
}
// Категории сверху - тоже в glass стиле
Row(
modifier = Modifier
.fillMaxWidth()
.background(categoryBackground)
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(categoryBarBackground)
.padding(horizontal = 4.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
@@ -279,27 +383,29 @@ fun AppleEmojiPickerPanel(
CategoryButton(
category = category,
isSelected = selectedCategory == category,
onClick = { selectedCategory = category }
onClick = { selectedCategory = category },
isDarkTheme = isDarkTheme
)
}
}
Divider(
color = dividerColor,
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(8.dp))
// Сетка эмодзи
// Сетка эмодзи с оптимизированной загрузкой
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(9),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 8.dp, vertical = 8.dp),
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(currentEmojis) { unified ->
items(
items = currentEmojis,
key = { it } // Используем unified как ключ для оптимизации
) { unified ->
EmojiButton(
unified = unified,
onClick = { emoji ->
@@ -308,5 +414,8 @@ fun AppleEmojiPickerPanel(
)
}
}
// Отступ снизу для navigation bar
Spacer(modifier = Modifier.height(8.dp))
}
}