feat: Implement keyboard height provider and optimize emoji picker animations

This commit is contained in:
k1ngsterr1
2026-01-15 03:03:54 +05:00
parent c4043cd247
commit a075f98dcb
3 changed files with 183 additions and 29 deletions

View File

@@ -2011,17 +2011,39 @@ private fun MessageInputBar(
val imeHeight = with(density) { imeInsets.getBottom(density).toDp() } val imeHeight = with(density) { imeInsets.getBottom(density).toDp() }
val isKeyboardVisible = imeHeight > 50.dp // 🔥 Согласованный порог с ChatDetailScreen val isKeyboardVisible = imeHeight > 50.dp // 🔥 Согласованный порог с ChatDetailScreen
// 🔥 Запоминаем высоту клавиатуры когда она открыта // 🔥 Флаг "клавиатура в процессе анимации"
// Дефолт 320.dp - хорошая высота для большинства устройств var isKeyboardAnimating by remember { mutableStateOf(false) }
var savedKeyboardHeight by remember { mutableStateOf(320.dp) }
// 🔥 Логирование изменений высоты клавиатуры
LaunchedEffect(imeHeight) { LaunchedEffect(imeHeight) {
if (imeHeight > 100.dp) { android.util.Log.d("KeyboardHeight", "📊 IME height: $imeHeight (visible=$isKeyboardVisible, showEmojiPicker=$showEmojiPicker, animating=$isKeyboardAnimating)")
savedKeyboardHeight = imeHeight
}
} }
// Высота панели эмодзи = сохранённая высота клавиатуры (минимум 280.dp) // 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences)
val emojiPanelHeight = maxOf(savedKeyboardHeight, 280.dp) 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 // Состояние отправки - можно отправить если есть текст ИЛИ есть reply
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
@@ -2049,14 +2071,19 @@ private fun MessageInputBar(
fun toggleEmojiPicker() { fun toggleEmojiPicker() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 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) { if (showEmojiPicker) {
// ========== ЗАКРЫВАЕМ EMOJI → ОТКРЫВАЕМ КЛАВИАТУРУ ========== // ========== ЗАКРЫВАЕМ 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 панель // Шаг 1: Закрываем emoji панель
onToggleEmojiPicker(false) onToggleEmojiPicker(false)
android.util.Log.d("EmojiPicker", " [1] Emoji panel closed")
// Шаг 2: Немедленно фокусируем и открываем клавиатуру // Шаг 2: Немедленно фокусируем и открываем клавиатуру
editTextView?.let { editText -> editTextView?.let { editText ->
@@ -2064,44 +2091,51 @@ private fun MessageInputBar(
// Метод 1: Немедленный вызов // Метод 1: Немедленный вызов
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) 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) // Метод 2: Через post (следующий frame)
view.post { view.post {
val elapsed = System.currentTimeMillis() - startTime
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) 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) // Метод 3: Через postDelayed (100ms)
view.postDelayed({ view.postDelayed({
val elapsed = System.currentTimeMillis() - startTime
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) 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) }, 100)
// Метод 4: Финальная попытка через 200ms // Метод 4: Финальная попытка через 200ms
view.postDelayed({ view.postDelayed({
val elapsed = System.currentTimeMillis() - startTime
if (!isKeyboardVisible) { 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) imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
} else { } else {
android.util.Log.d("EmojiPicker", "✅ Keyboard is visible!") android.util.Log.d("EmojiPicker", " [5] ✅ Keyboard opened successfully in ${elapsed}ms!")
} }
}, 200) }, 200)
} ?: android.util.Log.e("EmojiPicker", " editTextView is null!") } ?: android.util.Log.e("EmojiPicker", " ❌ ERROR: editTextView is null!")
} else { } else {
// ========== ОТКРЫВАЕМ EMOJI → ЗАКРЫВАЕМ КЛАВИАТУРУ ========== // ========== ОТКРЫВАЕМ EMOJI → ЗАКРЫВАЕМ КЛАВИАТУРУ ==========
android.util.Log.d("EmojiPicker", "😊 Opening emoji, closing keyboard") android.util.Log.d("EmojiPicker", "😊 Action: OPENING emoji → CLOSING keyboard")
// Шаг 1: Скрываем клавиатуру // Шаг 1: Скрываем клавиатуру
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
android.util.Log.d("EmojiPicker", " [1] Keyboard hide requested")
// Шаг 2: Небольшая задержка для плавности // Шаг 2: Небольшая задержка для плавности
view.postDelayed({ view.postDelayed({
// Шаг 3: Открываем emoji панель // Шаг 3: Открываем emoji панель
onToggleEmojiPicker(true) onToggleEmojiPicker(true)
android.util.Log.d("EmojiPicker", "✅ Emoji panel opened") android.util.Log.d("EmojiPicker", " [2] ✅ Emoji panel opened (50ms delay)")
}, 50) }, 50)
} }
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker END")
android.util.Log.d("EmojiPicker", "=".repeat(60))
} }
// Функция отправки - НЕ закрывает клавиатуру (UX правило #6) // Функция отправки - НЕ закрывает клавиатуру (UX правило #6)

View File

@@ -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
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -44,9 +45,10 @@ import kotlinx.coroutines.launch
* 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout) * 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout)
* 3. Hardware layer для анимаций * 3. Hardware layer для анимаций
* 4. Минимум recomposition (derivedStateOf, remember keys) * 4. Минимум recomposition (derivedStateOf, remember keys)
* 5. Smooth slide + fade transitions * 5. Smooth slide + fade transitions (Telegram-style)
* 6. Coil оптимизация (hardware acceleration, size limits) * 6. Coil оптимизация (hardware acceleration, size limits)
* 7. Нет лишних indications/ripples * 7. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
* 8. keyboardDuration для синхронизации с системной клавиатурой
* *
* @param isVisible Видимость панели * @param isVisible Видимость панели
* @param isDarkTheme Темная/светлая тема * @param isDarkTheme Темная/светлая тема
@@ -63,36 +65,47 @@ fun OptimizedEmojiPicker(
onClose: () -> Unit = {}, onClose: () -> Unit = {},
modifier: Modifier = Modifier 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( AnimatedVisibility(
visible = isVisible, visible = isVisible,
enter = slideInVertically( enter = slideInVertically(
initialOffsetY = { it }, initialOffsetY = { it },
animationSpec = tween( animationSpec = tween(
durationMillis = 180, // 🔥 Быстрее! durationMillis = animationDuration, // 🔥 Telegram's 250ms
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
) )
) + fadeIn( ) + fadeIn(
animationSpec = tween( animationSpec = tween(
durationMillis = 120, durationMillis = animationDuration / 2,
easing = LinearEasing easing = LinearEasing
) )
), ),
exit = slideOutVertically( exit = slideOutVertically(
targetOffsetY = { it }, targetOffsetY = { it },
animationSpec = tween( animationSpec = tween(
durationMillis = 150, // 🔥 Быстрое закрытие durationMillis = (animationDuration * 0.8).toInt(), // 🔥 Быстрое закрытие (200ms)
easing = FastOutLinearInEasing easing = FastOutLinearInEasing
) )
) + fadeOut( ) + fadeOut(
animationSpec = tween( animationSpec = tween(
durationMillis = 100, durationMillis = (animationDuration * 0.6).toInt(),
easing = LinearEasing easing = LinearEasing
) )
), ),
modifier = modifier modifier = modifier
) { ) {
// 🎨 Hardware layer для анимаций (GPU ускорение) // 🎨 Hardware layer для анимаций (GPU ускорение - как в Telegram)
Box( Box(
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
// Используем hardware layer только во время анимации // Используем hardware layer только во время анимации
@@ -103,7 +116,8 @@ fun OptimizedEmojiPicker(
) { ) {
EmojiPickerContent( EmojiPickerContent(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected onEmojiSelected = onEmojiSelected,
keyboardHeight = savedKeyboardHeight
) )
} }
} }
@@ -115,7 +129,8 @@ fun OptimizedEmojiPicker(
@Composable @Composable
private fun EmojiPickerContent( private fun EmojiPickerContent(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onEmojiSelected: (String) -> Unit onEmojiSelected: (String) -> Unit,
keyboardHeight: Dp
) { ) {
val context = LocalContext.current val context = LocalContext.current
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
@@ -126,13 +141,19 @@ private fun EmojiPickerContent(
var shouldRenderContent by remember { mutableStateOf(false) } var shouldRenderContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
android.util.Log.d("EmojiPicker", "🚀 EmojiPickerContent started, keyboardHeight=$keyboardHeight")
// Ждём 1 кадр чтобы анимация началась плавно // Ждём 1 кадр чтобы анимация началась плавно
kotlinx.coroutines.delay(16) // ~1 frame at 60fps kotlinx.coroutines.delay(16) // ~1 frame at 60fps
shouldRenderContent = true shouldRenderContent = true
android.util.Log.d("EmojiPicker", "✅ Content rendering enabled after 16ms delay")
// Загружаем эмодзи если еще не загружены // Загружаем эмодзи если еще не загружены
if (!OptimizedEmojiCache.isLoaded) { if (!OptimizedEmojiCache.isLoaded) {
android.util.Log.d("EmojiPicker", "📦 Starting emoji preload...")
OptimizedEmojiCache.preload(context) OptimizedEmojiCache.preload(context)
} else {
android.util.Log.d("EmojiPicker", "✅ Emojis already loaded")
} }
} }
@@ -149,9 +170,11 @@ private fun EmojiPickerContent(
// 🚀 При смене категории плавно скроллим наверх // 🚀 При смене категории плавно скроллим наверх
LaunchedEffect(selectedCategory) { LaunchedEffect(selectedCategory) {
android.util.Log.d("EmojiPicker", "📂 Category changed: ${selectedCategory.key} (${displayedEmojis.size} emojis)")
if (displayedEmojis.isNotEmpty()) { if (displayedEmojis.isNotEmpty()) {
scope.launch { scope.launch {
gridState.animateScrollToItem(0) gridState.animateScrollToItem(0)
android.util.Log.v("EmojiPicker", "⬆️ Scrolled to top")
} }
} }
} }
@@ -164,7 +187,7 @@ private fun EmojiPickerContent(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(350.dp) // Фиксированная высота как у клавиатуры .height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram)
.background(panelBackground) .background(panelBackground)
) { ) {
// 🔥 Показываем пустую панель пока не готово // 🔥 Показываем пустую панель пока не готово