From a075f98dcbb188ecc81b499f0b72532712b62e97 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 15 Jan 2026 03:03:54 +0500 Subject: [PATCH] feat: Implement keyboard height provider and optimize emoji picker animations --- .../messenger/ui/chats/ChatDetailScreen.kt | 70 +++++++++---- .../ui/components/KeyboardHeightProvider.kt | 97 +++++++++++++++++++ .../ui/components/OptimizedEmojiPicker.kt | 45 ++++++--- 3 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/KeyboardHeightProvider.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 1db7846..52c29f1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2011,17 +2011,39 @@ private fun MessageInputBar( val imeHeight = with(density) { imeInsets.getBottom(density).toDp() } val isKeyboardVisible = imeHeight > 50.dp // 🔥 Согласованный порог с ChatDetailScreen - // 🔥 Запоминаем высоту клавиатуры когда она открыта - // Дефолт 320.dp - хорошая высота для большинства устройств - var savedKeyboardHeight by remember { mutableStateOf(320.dp) } + // 🔥 Флаг "клавиатура в процессе анимации" + var isKeyboardAnimating by remember { mutableStateOf(false) } + + // 🔥 Логирование изменений высоты клавиатуры LaunchedEffect(imeHeight) { - if (imeHeight > 100.dp) { - savedKeyboardHeight = imeHeight - } + android.util.Log.d("KeyboardHeight", "📊 IME height: $imeHeight (visible=$isKeyboardVisible, showEmojiPicker=$showEmojiPicker, animating=$isKeyboardAnimating)") } - // Высота панели эмодзи = сохранённая высота клавиатуры (минимум 280.dp) - val emojiPanelHeight = maxOf(savedKeyboardHeight, 280.dp) + // 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences) + LaunchedEffect(Unit) { + // Загружаем сохранённую высоту при старте + val savedHeightPx = com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context) + android.util.Log.d("KeyboardHeight", "📱 MessageInputBar initialized, loaded height: ${savedHeightPx}px") + } + + // 🔥 Сохраняем высоту ТОЛЬКО когда клавиатура стабильна + // Используем отдельный LaunchedEffect который НЕ реагирует на каждое изменение + LaunchedEffect(isKeyboardVisible, showEmojiPicker) { + // Если клавиатура стала видимой и emoji закрыт + if (isKeyboardVisible && !showEmojiPicker && !isKeyboardAnimating) { + // Ждем стабилизации + isKeyboardAnimating = true + kotlinx.coroutines.delay(300) // Анимация клавиатуры ~250ms + isKeyboardAnimating = false + + // Сохраняем только если всё еще видна и emoji закрыт + if (isKeyboardVisible && !showEmojiPicker && imeHeight > 300.dp) { + val heightPx = with(density) { imeHeight.toPx().toInt() } + com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight(context, heightPx) + android.util.Log.d("KeyboardHeight", "✅ Stable keyboard height saved: ${imeHeight} (${heightPx}px)") + } + } + } // Состояние отправки - можно отправить если есть текст ИЛИ есть reply val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } @@ -2049,14 +2071,19 @@ private fun MessageInputBar( fun toggleEmojiPicker() { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker called, showEmojiPicker=$showEmojiPicker") + android.util.Log.d("EmojiPicker", "=".repeat(60)) + android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker START") + android.util.Log.d("EmojiPicker", " State: showEmojiPicker=$showEmojiPicker, isKeyboardVisible=$isKeyboardVisible") + android.util.Log.d("EmojiPicker", " IME height: $imeHeight, editTextView=${if (editTextView != null) "SET" else "NULL"}") if (showEmojiPicker) { // ========== ЗАКРЫВАЕМ EMOJI → ОТКРЫВАЕМ КЛАВИАТУРУ ========== - android.util.Log.d("EmojiPicker", "📱 Closing emoji, opening keyboard") + android.util.Log.d("EmojiPicker", "📱 Action: CLOSING emoji → OPENING keyboard") + val startTime = System.currentTimeMillis() // Шаг 1: Закрываем emoji панель onToggleEmojiPicker(false) + android.util.Log.d("EmojiPicker", " [1] Emoji panel closed") // Шаг 2: Немедленно фокусируем и открываем клавиатуру editTextView?.let { editText -> @@ -2064,44 +2091,51 @@ private fun MessageInputBar( // Метод 1: Немедленный вызов imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) - android.util.Log.d("EmojiPicker", "✅ Called showSoftInput FORCED (immediate)") + android.util.Log.d("EmojiPicker", " [2] Method 1: showSoftInput(FORCED) called") // Метод 2: Через post (следующий frame) view.post { + val elapsed = System.currentTimeMillis() - startTime imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) - android.util.Log.d("EmojiPicker", "✅ Called showSoftInput IMPLICIT (post)") + android.util.Log.d("EmojiPicker", " [3] Method 2: showSoftInput(IMPLICIT) called (${elapsed}ms)") } // Метод 3: Через postDelayed (100ms) view.postDelayed({ + val elapsed = System.currentTimeMillis() - startTime imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) - android.util.Log.d("EmojiPicker", "✅ Called toggleSoftInput (100ms)") + android.util.Log.d("EmojiPicker", " [4] Method 3: toggleSoftInput called (${elapsed}ms)") }, 100) // Метод 4: Финальная попытка через 200ms view.postDelayed({ + val elapsed = System.currentTimeMillis() - startTime if (!isKeyboardVisible) { - android.util.Log.d("EmojiPicker", "⚠️ Keyboard still not visible, forcing again") + android.util.Log.w("EmojiPicker", " [5] ⚠️ Keyboard still not visible after ${elapsed}ms, forcing again") imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) } else { - android.util.Log.d("EmojiPicker", "✅ Keyboard is visible!") + android.util.Log.d("EmojiPicker", " [5] ✅ Keyboard opened successfully in ${elapsed}ms!") } }, 200) - } ?: android.util.Log.e("EmojiPicker", "❌ editTextView is null!") + } ?: android.util.Log.e("EmojiPicker", " ❌ ERROR: editTextView is null!") } else { // ========== ОТКРЫВАЕМ EMOJI → ЗАКРЫВАЕМ КЛАВИАТУРУ ========== - android.util.Log.d("EmojiPicker", "😊 Opening emoji, closing keyboard") + android.util.Log.d("EmojiPicker", "😊 Action: OPENING emoji → CLOSING keyboard") // Шаг 1: Скрываем клавиатуру imm.hideSoftInputFromWindow(view.windowToken, 0) + android.util.Log.d("EmojiPicker", " [1] Keyboard hide requested") // Шаг 2: Небольшая задержка для плавности view.postDelayed({ // Шаг 3: Открываем emoji панель onToggleEmojiPicker(true) - android.util.Log.d("EmojiPicker", "✅ Emoji panel opened") + android.util.Log.d("EmojiPicker", " [2] ✅ Emoji panel opened (50ms delay)") }, 50) } + + android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker END") + android.util.Log.d("EmojiPicker", "=".repeat(60)) } // Функция отправки - НЕ закрывает клавиатуру (UX правило #6) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardHeightProvider.kt b/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardHeightProvider.kt new file mode 100644 index 0000000..35f6095 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardHeightProvider.kt @@ -0,0 +1,97 @@ +package com.rosetta.messenger.ui.components + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * 🎯 KEYBOARD HEIGHT PROVIDER + * + * Вдохновлено Telegram's EmojiView.java: + * - Сохраняет высоту клавиатуры в SharedPreferences + * - Использует сохранённую высоту для emoji picker + * - Обновляет при изменении высоты клавиатуры + * + * Telegram код: + * ```java + * keyboardHeight = MessagesController.getGlobalEmojiSettings() + * .getInt("kbd_height", AndroidUtilities.dp(200)); + * ``` + */ +object KeyboardHeightProvider { + private const val PREFS_NAME = "emoji_keyboard_prefs" + private const val KEY_KEYBOARD_HEIGHT = "kbd_height" + private const val DEFAULT_HEIGHT_DP = 280 // Telegram uses 200, we use 280 + + /** + * Получить сохранённую высоту клавиатуры + */ + fun getSavedKeyboardHeight(context: Context): Int { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val defaultPx = dpToPx(context, DEFAULT_HEIGHT_DP) + val savedPx = prefs.getInt(KEY_KEYBOARD_HEIGHT, defaultPx) + val isDefault = savedPx == defaultPx + + android.util.Log.d("KeyboardHeight", "📖 getSavedKeyboardHeight: ${savedPx}px (${pxToDp(context, savedPx)}dp) ${if (isDefault) "[DEFAULT]" else "[SAVED]"}") + return savedPx + } + + /** + * Сохранить высоту клавиатуры + */ + fun saveKeyboardHeight(context: Context, heightPx: Int) { + if (heightPx > 0) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val oldHeight = prefs.getInt(KEY_KEYBOARD_HEIGHT, -1) + val changed = oldHeight != heightPx + + prefs.edit().putInt(KEY_KEYBOARD_HEIGHT, heightPx).apply() + + if (changed) { + android.util.Log.d("KeyboardHeight", "💾 SAVED keyboard height: ${heightPx}px (${pxToDp(context, heightPx)}dp) [WAS: ${oldHeight}px]") + } else { + android.util.Log.v("KeyboardHeight", "💾 Same keyboard height: ${heightPx}px (no change)") + } + } else { + android.util.Log.w("KeyboardHeight", "⚠️ Attempted to save invalid height: ${heightPx}px") + } + } + + /** + * Получить длительность анимации клавиатуры + * Telegram использует: AdjustPanLayoutHelper.keyboardDuration (250ms обычно) + */ + fun getKeyboardAnimationDuration(): Long = 250L + + // Helper functions + private fun dpToPx(context: Context, dp: Int): Int { + val density = context.resources.displayMetrics.density + return (dp * density).toInt() + } + + private fun pxToDp(context: Context, px: Int): Int { + val density = context.resources.displayMetrics.density + return (px / density).toInt() + } +} + +/** + * Composable для получения высоты клавиатуры из SharedPreferences + * НЕ использует remember - всегда берет актуальное значение из SharedPreferences + */ +@Composable +fun rememberSavedKeyboardHeight(): Dp { + val context = LocalContext.current + + // 🔥 НЕ используем remember - каждый раз читаем актуальное значение + val heightPx = KeyboardHeightProvider.getSavedKeyboardHeight(context) + val density = context.resources.displayMetrics.density + val heightDp = (heightPx / density).dp + + android.util.Log.d("KeyboardHeight", "🎯 rememberSavedKeyboardHeight: ${heightDp} (${heightPx}px, density=${density})") + + return heightDp +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt index b932591..c7576eb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage @@ -44,9 +45,10 @@ import kotlinx.coroutines.launch * 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout) * 3. Hardware layer для анимаций * 4. Минимум recomposition (derivedStateOf, remember keys) - * 5. Smooth slide + fade transitions + * 5. Smooth slide + fade transitions (Telegram-style) * 6. Coil оптимизация (hardware acceleration, size limits) - * 7. Нет лишних indications/ripples + * 7. SharedPreferences для сохранения высоты клавиатуры (как в Telegram) + * 8. keyboardDuration для синхронизации с системной клавиатурой * * @param isVisible Видимость панели * @param isDarkTheme Темная/светлая тема @@ -63,36 +65,47 @@ fun OptimizedEmojiPicker( onClose: () -> Unit = {}, modifier: Modifier = Modifier ) { - // 🎭 Быстрая и плавная анимация (как в Telegram) + // 🔥 Используем сохранённую высоту клавиатуры (как в Telegram) + val savedKeyboardHeight = rememberSavedKeyboardHeight() + + // 🔥 Telegram's keyboardDuration для синхронизации анимации + val animationDuration = KeyboardHeightProvider.getKeyboardAnimationDuration().toInt() + + // 🔥 Логирование изменений видимости + LaunchedEffect(isVisible) { + android.util.Log.d("EmojiPicker", "🎭 OptimizedEmojiPicker visibility changed: $isVisible (height=${savedKeyboardHeight}, animDuration=${animationDuration}ms)") + } + + // 🎭 Telegram-style анимация: используем сохранённую длительность AnimatedVisibility( visible = isVisible, enter = slideInVertically( initialOffsetY = { it }, animationSpec = tween( - durationMillis = 180, // 🔥 Быстрее! + durationMillis = animationDuration, // 🔥 Telegram's 250ms easing = FastOutSlowInEasing ) ) + fadeIn( animationSpec = tween( - durationMillis = 120, + durationMillis = animationDuration / 2, easing = LinearEasing ) ), exit = slideOutVertically( targetOffsetY = { it }, animationSpec = tween( - durationMillis = 150, // 🔥 Быстрое закрытие + durationMillis = (animationDuration * 0.8).toInt(), // 🔥 Быстрое закрытие (200ms) easing = FastOutLinearInEasing ) ) + fadeOut( animationSpec = tween( - durationMillis = 100, + durationMillis = (animationDuration * 0.6).toInt(), easing = LinearEasing ) ), modifier = modifier ) { - // 🎨 Hardware layer для анимаций (GPU ускорение) + // 🎨 Hardware layer для анимаций (GPU ускорение - как в Telegram) Box( modifier = Modifier.graphicsLayer { // Используем hardware layer только во время анимации @@ -103,7 +116,8 @@ fun OptimizedEmojiPicker( ) { EmojiPickerContent( isDarkTheme = isDarkTheme, - onEmojiSelected = onEmojiSelected + onEmojiSelected = onEmojiSelected, + keyboardHeight = savedKeyboardHeight ) } } @@ -115,7 +129,8 @@ fun OptimizedEmojiPicker( @Composable private fun EmojiPickerContent( isDarkTheme: Boolean, - onEmojiSelected: (String) -> Unit + onEmojiSelected: (String) -> Unit, + keyboardHeight: Dp ) { val context = LocalContext.current var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } @@ -126,13 +141,19 @@ private fun EmojiPickerContent( var shouldRenderContent by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + android.util.Log.d("EmojiPicker", "🚀 EmojiPickerContent started, keyboardHeight=$keyboardHeight") + // Ждём 1 кадр чтобы анимация началась плавно kotlinx.coroutines.delay(16) // ~1 frame at 60fps shouldRenderContent = true + android.util.Log.d("EmojiPicker", "✅ Content rendering enabled after 16ms delay") // Загружаем эмодзи если еще не загружены if (!OptimizedEmojiCache.isLoaded) { + android.util.Log.d("EmojiPicker", "📦 Starting emoji preload...") OptimizedEmojiCache.preload(context) + } else { + android.util.Log.d("EmojiPicker", "✅ Emojis already loaded") } } @@ -149,9 +170,11 @@ private fun EmojiPickerContent( // 🚀 При смене категории плавно скроллим наверх LaunchedEffect(selectedCategory) { + android.util.Log.d("EmojiPicker", "📂 Category changed: ${selectedCategory.key} (${displayedEmojis.size} emojis)") if (displayedEmojis.isNotEmpty()) { scope.launch { gridState.animateScrollToItem(0) + android.util.Log.v("EmojiPicker", "⬆️ Scrolled to top") } } } @@ -164,7 +187,7 @@ private fun EmojiPickerContent( Column( modifier = Modifier .fillMaxWidth() - .height(350.dp) // Фиксированная высота как у клавиатуры + .height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram) .background(panelBackground) ) { // 🔥 Показываем пустую панель пока не готово