feat: Optimize emoji picker with chunk loading and improve typing indicator in chat list
This commit is contained in:
@@ -2298,34 +2298,59 @@ private fun MessageInputBar(
|
|||||||
} // Закрытие внешней Column с border
|
} // Закрытие внешней Column с border
|
||||||
} // End of else (not blocked)
|
} // End of else (not blocked)
|
||||||
|
|
||||||
// 🔥 APPLE EMOJI PICKER - БЕЗ анимации когда клавиатура открывается
|
// <EFBFBD> APPLE EMOJI PICKER - ОПТИМИЗИРОВАННАЯ АНИМАЦИЯ
|
||||||
|
// Используем slide up + alpha вместо height анимации (нет relayout = супер плавно)
|
||||||
if (!isBlocked) {
|
if (!isBlocked) {
|
||||||
// Высота панели: 0 если клавиатура видна или эмодзи закрыты, иначе emojiPanelHeight
|
// Панель ВСЕГДА в DOM (pre-render), просто скрыта через offset/alpha
|
||||||
// НЕ анимируем когда клавиатура открыта (чтобы не было прыжка)
|
val shouldShow = showEmojiPicker && !isKeyboardVisible
|
||||||
val targetHeight = if (isKeyboardVisible || !showEmojiPicker) 0.dp else emojiPanelHeight
|
|
||||||
|
|
||||||
// Анимируем только когда клавиатура закрыта
|
// 🚀 Animatable для максимального контроля (быстрее чем animateDpAsState)
|
||||||
val animatedHeight by animateDpAsState(
|
val slideProgress = remember { Animatable(0f) }
|
||||||
targetValue = targetHeight,
|
|
||||||
animationSpec = if (isKeyboardVisible) {
|
LaunchedEffect(shouldShow) {
|
||||||
// Мгновенно когда клавиатура открывается
|
if (shouldShow) {
|
||||||
snap()
|
// Открытие: быстрая spring анимация (critically damped = без пружинистости)
|
||||||
|
slideProgress.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 1f, // Critically damped - без bounce
|
||||||
|
stiffness = 800f // Высокая жёсткость = быстро
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Быстрая анимация как обычная клавиатура (200ms)
|
// Закрытие: мгновенно если клавиатура открылась, иначе быстрая анимация
|
||||||
tween(durationMillis = 200, easing = FastOutSlowInEasing)
|
if (isKeyboardVisible) {
|
||||||
},
|
slideProgress.snapTo(0f)
|
||||||
label = "EmojiPanelHeight"
|
} else {
|
||||||
)
|
slideProgress.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(120, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val panelHeightPx = with(LocalDensity.current) { (emojiPanelHeight - 16.dp).toPx() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(animatedHeight)
|
.height(emojiPanelHeight)
|
||||||
.padding(bottom = 16.dp) // 🔥 Отступ снизу для безопасной зоны
|
.padding(bottom = 16.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
// 🚀 Slide up анимация через translationY (НЕ вызывает relayout!)
|
||||||
|
translationY = panelHeightPx * (1f - slideProgress.value)
|
||||||
|
alpha = slideProgress.value
|
||||||
|
// Hardware layer для плавности
|
||||||
|
if (slideProgress.value > 0f && slideProgress.value < 1f) {
|
||||||
|
shadowElevation = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipToBounds() // Обрезаем контент при slide
|
||||||
) {
|
) {
|
||||||
// 🚀 Рендерим панель когда showEmojiPicker = true
|
// 🚀 Панель ВСЕГДА рендерится (pre-render для instant open)
|
||||||
// Высота контролируется через animatedHeight
|
// Но скрыта через alpha = 0 когда не нужна
|
||||||
if (showEmojiPicker) {
|
if (slideProgress.value > 0f || shouldShow) {
|
||||||
AppleEmojiPickerPanel(
|
AppleEmojiPickerPanel(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onEmojiSelected = { emoji ->
|
onEmojiSelected = { emoji ->
|
||||||
@@ -2336,7 +2361,7 @@ private fun MessageInputBar(
|
|||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(emojiPanelHeight - 16.dp) // 🔥 Учитываем отступ
|
.height(emojiPanelHeight - 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.airbnb.lottie.compose.*
|
import com.airbnb.lottie.compose.*
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
@@ -179,6 +180,9 @@ fun ChatsListScreen(
|
|||||||
// Protocol connection state
|
// Protocol connection state
|
||||||
val protocolState by ProtocolManager.state.collectAsState()
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
|
|
||||||
|
// 🔥 Пользователи, которые сейчас печатают
|
||||||
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
|
|
||||||
// Dialogs from database
|
// Dialogs from database
|
||||||
val dialogsList by chatsViewModel.dialogs.collectAsState()
|
val dialogsList by chatsViewModel.dialogs.collectAsState()
|
||||||
|
|
||||||
@@ -508,6 +512,7 @@ fun ChatsListScreen(
|
|||||||
DialogItem(
|
DialogItem(
|
||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
isTyping = typingUsers.contains(dialog.opponentKey),
|
||||||
onClick = {
|
onClick = {
|
||||||
val user = chatsViewModel.dialogToSearchUser(dialog)
|
val user = chatsViewModel.dialogToSearchUser(dialog)
|
||||||
onUserSelect(user)
|
onUserSelect(user)
|
||||||
@@ -664,6 +669,8 @@ fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) {
|
|||||||
text = chat.lastMessage,
|
text = chat.lastMessage,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = secondaryTextColor,
|
color = secondaryTextColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -786,7 +793,7 @@ fun DrawerMenuItem(
|
|||||||
|
|
||||||
/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */
|
/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */
|
||||||
@Composable
|
@Composable
|
||||||
fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit) {
|
fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) {
|
||||||
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
|
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
|
||||||
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
||||||
val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||||
@@ -886,15 +893,22 @@ fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit)
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 🔥 Используем AppleEmojiText для отображения эмодзи
|
// 🔥 Показываем typing индикатор или последнее сообщение
|
||||||
// Если есть непрочитанные - текст темнее
|
if (isTyping) {
|
||||||
AppleEmojiText(
|
TypingIndicatorSmall()
|
||||||
text = dialog.lastMessage.ifEmpty { "No messages" },
|
} else {
|
||||||
fontSize = 14.sp,
|
// 🔥 Используем AppleEmojiText для отображения эмодзи
|
||||||
color = if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor,
|
// Если есть непрочитанные - текст темнее
|
||||||
fontWeight = if (dialog.unreadCount > 0) FontWeight.Medium else FontWeight.Normal,
|
AppleEmojiText(
|
||||||
modifier = Modifier.weight(1f)
|
text = dialog.lastMessage.ifEmpty { "No messages" },
|
||||||
)
|
fontSize = 14.sp,
|
||||||
|
color = if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor,
|
||||||
|
fontWeight = if (dialog.unreadCount > 0) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Unread badge
|
// Unread badge
|
||||||
if (dialog.unreadCount > 0) {
|
if (dialog.unreadCount > 0) {
|
||||||
@@ -933,3 +947,50 @@ fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 Компактный индикатор typing для списка чатов
|
||||||
|
* Голубой текст "typing" с анимированными точками
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TypingIndicatorSmall() {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||||
|
val typingColor = PrimaryBlue
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "typing",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = typingColor,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3 анимированные точки
|
||||||
|
repeat(3) { index ->
|
||||||
|
val offsetY by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = -3f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(
|
||||||
|
durationMillis = 500,
|
||||||
|
delayMillis = index * 120,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "dot$index"
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = ".",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = typingColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.offset(y = offsetY.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,7 +266,9 @@ fun AppleEmojiText(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White,
|
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White,
|
||||||
fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
|
fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||||
fontWeight: androidx.compose.ui.text.font.FontWeight? = null
|
fontWeight: androidx.compose.ui.text.font.FontWeight? = null,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
overflow: android.text.TextUtils.TruncateAt? = null
|
||||||
) {
|
) {
|
||||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||||
else fontSize.value
|
else fontSize.value
|
||||||
@@ -291,12 +293,22 @@ fun AppleEmojiText(
|
|||||||
setTextSize(fontSizeValue)
|
setTextSize(fontSizeValue)
|
||||||
minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt()
|
minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt()
|
||||||
setTypeface(typeface, typefaceStyle)
|
setTypeface(typeface, typefaceStyle)
|
||||||
|
// 🔥 Поддержка maxLines и ellipsize
|
||||||
|
setMaxLines(maxLines)
|
||||||
|
if (overflow != null) {
|
||||||
|
ellipsize = overflow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { view ->
|
update = { view ->
|
||||||
view.setTextWithEmojis(text)
|
view.setTextWithEmojis(text)
|
||||||
view.setTextColor(color.toArgb())
|
view.setTextColor(color.toArgb())
|
||||||
view.setTypeface(view.typeface, typefaceStyle)
|
view.setTypeface(view.typeface, typefaceStyle)
|
||||||
|
// 🔥 Обновляем maxLines и ellipsize
|
||||||
|
view.maxLines = maxLines
|
||||||
|
if (overflow != null) {
|
||||||
|
view.ellipsize = overflow
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import coil.request.CachePolicy
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -554,18 +555,7 @@ fun CategoryButton(
|
|||||||
isDarkTheme: Boolean = true,
|
isDarkTheme: Boolean = true,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
// 🚀 Убрали анимацию scale для производительности
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = if (isPressed) 0.9f else 1f,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
),
|
|
||||||
label = "categoryScale"
|
|
||||||
)
|
|
||||||
|
|
||||||
val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
|
val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
|
||||||
val iconTint = if (isSelected) PrimaryBlue
|
val iconTint = if (isSelected) PrimaryBlue
|
||||||
else if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
else if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
||||||
@@ -574,14 +564,9 @@ fun CategoryButton(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.scale(scale)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(
|
.clickable(onClick = onClick),
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = null,
|
|
||||||
onClick = onClick
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -614,20 +599,32 @@ fun AppleEmojiPickerPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Текущие эмодзи для выбранной категории - используем derivedStateOf для оптимизации
|
// 🚀 Chunk loading: показываем эмодзи порциями для плавности
|
||||||
val currentEmojis by remember {
|
var loadedCount by remember { mutableStateOf(40) } // Начинаем с 40 (5 рядов)
|
||||||
derivedStateOf {
|
|
||||||
if (EmojiCache.isLoaded) {
|
// Текущие эмодзи для выбранной категории
|
||||||
EmojiCache.getEmojisForCategory(selectedCategory.key)
|
val allEmojis = remember(selectedCategory, EmojiCache.isLoaded) {
|
||||||
} else {
|
if (EmojiCache.isLoaded) {
|
||||||
emptyList()
|
EmojiCache.getEmojisForCategory(selectedCategory.key)
|
||||||
}
|
} else {
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 Убираем анимацию скролла для мгновенного переключения категорий
|
// 🚀 При смене категории сбрасываем чанки и постепенно догружаем
|
||||||
LaunchedEffect(selectedCategory) {
|
LaunchedEffect(selectedCategory) {
|
||||||
|
loadedCount = 40 // Сразу показываем 40 эмодзи
|
||||||
gridState.scrollToItem(0)
|
gridState.scrollToItem(0)
|
||||||
|
// Догружаем остальные чанками
|
||||||
|
while (loadedCount < allEmojis.size) {
|
||||||
|
delay(32) // 2 фрейма
|
||||||
|
loadedCount = minOf(loadedCount + 24, allEmojis.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображаемые эмодзи (с chunk loading)
|
||||||
|
val displayedEmojis = remember(allEmojis, loadedCount) {
|
||||||
|
allEmojis.take(loadedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||||
@@ -680,7 +677,7 @@ fun AppleEmojiPickerPanel(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (currentEmojis.isEmpty()) {
|
} else if (displayedEmojis.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -694,7 +691,7 @@ fun AppleEmojiPickerPanel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🚀 Оптимизированная LazyVerticalGrid для быстрого рендеринга
|
// 🚀 Оптимизированная LazyVerticalGrid с chunk loading
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
columns = GridCells.Fixed(8),
|
columns = GridCells.Fixed(8),
|
||||||
@@ -706,7 +703,7 @@ fun AppleEmojiPickerPanel(
|
|||||||
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 16.dp)
|
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 16.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = currentEmojis,
|
items = displayedEmojis,
|
||||||
key = { emoji -> emoji },
|
key = { emoji -> emoji },
|
||||||
contentType = { "emoji" }
|
contentType = { "emoji" }
|
||||||
) { unified ->
|
) { unified ->
|
||||||
|
|||||||
Reference in New Issue
Block a user