feat: Implement smooth keyboard transition plan with Telegram-style animations

- Add KeyboardTransitionCoordinator for managing transitions between keyboard and emoji panel.
- Create AnimatedKeyboardTransition for handling emoji panel animations with slide and fade effects.
- Integrate keyboard transition logic into MessageInputBar for seamless emoji picker toggling.
- Update OptimizedEmojiPicker to utilize external animation management instead of internal visibility animations.
- Ensure synchronization of keyboard and emoji heights for consistent UI behavior.
This commit is contained in:
k1ngsterr1
2026-01-15 12:08:10 +05:00
parent a075f98dcb
commit 9f4e85d64a
6 changed files with 1300 additions and 155 deletions

View File

@@ -70,6 +70,8 @@ import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import android.view.inputmethod.InputMethodManager
import android.content.Context
import androidx.compose.ui.platform.LocalContext
@@ -1989,19 +1991,32 @@ private fun MessageInputBar(
val view = LocalView.current
val density = LocalDensity.current
// 🎯 Координатор плавных переходов клавиатуры (Telegram-style)
val coordinator = rememberKeyboardTransitionCoordinator()
// 🔥 Ссылка на EditText для программного фокуса
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
// 🔥 Автофокус при открытии reply панели
LaunchedEffect(hasReply, editTextView) {
if (hasReply) {
android.util.Log.d("EmojiPicker", "═══════════════════════════════════════════════════════")
android.util.Log.d("EmojiPicker", "💬 Reply panel opened, hasReply=$hasReply")
android.util.Log.d("EmojiPicker", " 📊 editTextView=$editTextView, showEmojiPicker=$showEmojiPicker")
// Даём время на создание view если ещё null
kotlinx.coroutines.delay(50)
editTextView?.let { editText ->
editText.requestFocus()
// Открываем клавиатуру
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
// 🔥 НЕ открываем клавиатуру если emoji уже открыт
if (!showEmojiPicker) {
android.util.Log.d("EmojiPicker", " ⌨️ Requesting focus and keyboard for reply...")
editText.requestFocus()
// Открываем клавиатуру
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
android.util.Log.d("EmojiPicker", " ✅ Auto-opened keyboard for reply")
} else {
android.util.Log.d("EmojiPicker", " ⏭️ Skip auto-keyboard for reply (emoji is open)")
}
}
}
}
@@ -2014,9 +2029,24 @@ private fun MessageInputBar(
// 🔥 Флаг "клавиатура в процессе анимации"
var isKeyboardAnimating by remember { mutableStateOf(false) }
// 🔥 Логирование изменений высоты клавиатуры
// 🔥 Логирование изменений высоты клавиатуры + обновление coordinator
LaunchedEffect(imeHeight) {
android.util.Log.d("KeyboardHeight", "📊 IME height: $imeHeight (visible=$isKeyboardVisible, showEmojiPicker=$showEmojiPicker, animating=$isKeyboardAnimating)")
android.util.Log.d("KeyboardHeight", "═══════════════════════════════════════════════════════")
android.util.Log.d("KeyboardHeight", "📊 IME height changed: $imeHeight")
android.util.Log.d("KeyboardHeight", " isKeyboardVisible=$isKeyboardVisible")
android.util.Log.d("KeyboardHeight", " showEmojiPicker=$showEmojiPicker")
android.util.Log.d("KeyboardHeight", " isKeyboardAnimating=$isKeyboardAnimating")
// Обновляем coordinator с актуальной высотой клавиатуры
android.util.Log.d("KeyboardHeight", "🔄 Updating coordinator...")
coordinator.updateKeyboardHeight(imeHeight)
// Синхронизируем высоту emoji с клавиатурой
if (imeHeight > 100.dp) {
android.util.Log.d("KeyboardHeight", "🔄 Syncing heights...")
coordinator.syncHeights()
}
android.util.Log.d("KeyboardHeight", "✅ Coordinator updated")
}
// 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences)
@@ -2067,71 +2097,45 @@ private fun MessageInputBar(
focusManager.clearFocus(force = true)
}
// 🔥 Функция переключения emoji picker - МАКСИМАЛЬНО АГРЕССИВНОЕ ОТКРЫТИЕ КЛАВИАТУРЫ
// 🔥 Функция переключения emoji picker с Telegram-style transitions
fun toggleEmojiPicker() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
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"}")
android.util.Log.d("EmojiPicker", " showEmojiPicker(local)=$showEmojiPicker")
coordinator.logState()
if (showEmojiPicker) {
// ========== ЗАКРЫВАЕМ EMOJI → ОТКРЫВАЕМ КЛАВИАТУРУ ==========
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 ->
editText.requestFocus()
// Метод 1: Немедленный вызов
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
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", " [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", " [4] Method 3: toggleSoftInput called (${elapsed}ms)")
}, 100)
// Метод 4: Финальная попытка через 200ms
view.postDelayed({
val elapsed = System.currentTimeMillis() - startTime
if (!isKeyboardVisible) {
android.util.Log.w("EmojiPicker", " [5] ⚠️ Keyboard still not visible after ${elapsed}ms, forcing again")
// 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного состояния
if (coordinator.isEmojiVisible) {
// ========== EMOJI → KEYBOARD ==========
android.util.Log.d("EmojiPicker", "🔄 Switching: Emoji → Keyboard")
coordinator.requestShowKeyboard(
showKeyboard = {
editTextView?.let { editText ->
editText.requestFocus()
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
} else {
android.util.Log.d("EmojiPicker", " [5] ✅ Keyboard opened successfully in ${elapsed}ms!")
android.util.Log.d("EmojiPicker", "📱 Keyboard show requested")
}
}, 200)
} ?: android.util.Log.e("EmojiPicker", " ❌ ERROR: editTextView is null!")
},
hideEmoji = {
onToggleEmojiPicker(false)
android.util.Log.d("EmojiPicker", "😊 Emoji panel hidden")
}
)
} else {
// ========== ОТКРЫВАЕМ EMOJI → ЗАКРЫВАЕМ КЛАВИАТУРУ ==========
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", " [2] ✅ Emoji panel opened (50ms delay)")
}, 50)
// ========== KEYBOARD → EMOJI ==========
android.util.Log.d("EmojiPicker", "🔄 Switching: Keyboard → Emoji")
coordinator.requestShowEmoji(
hideKeyboard = {
imm.hideSoftInputFromWindow(view.windowToken, 0)
android.util.Log.d("EmojiPicker", "⌨️ Keyboard hide requested")
},
showEmoji = {
onToggleEmojiPicker(true)
android.util.Log.d("EmojiPicker", "😊 Emoji panel shown")
}
)
}
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker END")
@@ -2309,26 +2313,6 @@ private fun MessageInputBar(
.background(
color = backgroundColor // Тот же цвет что и фон чата
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
// При клике на инпут - закрываем эмодзи панель и открываем клавиатуру
if (showEmojiPicker) {
onToggleEmojiPicker(false)
// Открываем клавиатуру после небольшой задержки
view.postDelayed({
editTextView?.let { editText ->
editText.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
}
}, 100)
} else {
// Просто фокусируем для открытия клавиатуры
editTextView?.requestFocus()
}
}
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался
) {
@@ -2344,7 +2328,29 @@ private fun MessageInputBar(
onViewCreated = { view ->
// 🔥 Сохраняем ссылку на EditText для программного открытия клавиатуры
editTextView = view
android.util.Log.d("EmojiPicker", "✅ editTextView set: $view")
},
onFocusChanged = { hasFocus ->
android.util.Log.d("EmojiPicker", "═══════════════════════════════════════════════════════")
android.util.Log.d("EmojiPicker", "🎯 TextField focus changed: hasFocus=$hasFocus")
android.util.Log.d("EmojiPicker", " 📊 Current state:")
android.util.Log.d("EmojiPicker", " - showEmojiPicker=$showEmojiPicker")
android.util.Log.d("EmojiPicker", " - coordinator.isEmojiVisible=${coordinator.isEmojiVisible}")
android.util.Log.d("EmojiPicker", " - coordinator.isKeyboardVisible=${coordinator.isKeyboardVisible}")
android.util.Log.d("EmojiPicker", " - coordinator.currentState=${coordinator.currentState}")
// Если TextField получил фокус И emoji открыт → закрываем emoji
if (hasFocus && showEmojiPicker) {
android.util.Log.d("EmojiPicker", "🔄 TextField focused while emoji open → closing emoji")
android.util.Log.d("EmojiPicker", " 📞 Calling onToggleEmojiPicker(false)...")
onToggleEmojiPicker(false)
android.util.Log.d("EmojiPicker", " 📞 Setting coordinator.isEmojiVisible = false...")
coordinator.isEmojiVisible = false
android.util.Log.d("EmojiPicker", " ✅ Emoji close requested")
} else if (hasFocus && !showEmojiPicker) {
android.util.Log.d("EmojiPicker", "⌨️ TextField focused with emoji closed → normal keyboard behavior")
} else if (!hasFocus) {
android.util.Log.d("EmojiPicker", "👋 TextField lost focus")
}
}
)
}
@@ -2391,22 +2397,25 @@ private fun MessageInputBar(
} // End of else (not blocked)
// 🔥 ОПТИМИЗИРОВАННЫЙ EMOJI PICKER - с предзагрузкой и smooth animations
// 🔥 EMOJI PICKER с плавными Telegram-style анимациями
if (!isBlocked) {
// Новый оптимизированный пикер автоматически управляет анимациями
OptimizedEmojiPicker(
isVisible = showEmojiPicker && !isKeyboardVisible,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji ->
onValueChange(value + emoji)
},
onClose = {
// 🔥 Используем toggleEmojiPicker() - он закроет панель и откроет клавиатуру
toggleEmojiPicker()
},
modifier = Modifier
.fillMaxWidth()
)
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker
) {
OptimizedEmojiPicker(
isVisible = true, // Видимость контролирует AnimatedKeyboardTransition
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji ->
onValueChange(value + emoji)
},
onClose = {
// Используем coordinator для плавного перехода
toggleEmojiPicker()
},
modifier = Modifier.fillMaxWidth()
)
}
} // End of if (!isBlocked) for emoji picker
}
}

View File

@@ -208,7 +208,8 @@ fun AppleEmojiTextField(
hint: String = "Message",
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
requestFocus: Boolean = false
requestFocus: Boolean = false,
onFocusChanged: ((Boolean) -> Unit)? = null
) {
// Храним ссылку на view для управления фокусом
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
@@ -236,6 +237,14 @@ fun AppleEmojiTextField(
setBackgroundColor(android.graphics.Color.TRANSPARENT)
// Сохраняем ссылку на view
editTextView = this
// Подключаем callback для изменения фокуса
setOnFocusChangeListener { _, hasFocus ->
android.util.Log.d("AppleEmojiTextField", "═══════════════════════════════════════════════════════")
android.util.Log.d("AppleEmojiTextField", "🎯 Native EditText focus changed: hasFocus=$hasFocus")
android.util.Log.d("AppleEmojiTextField", " 📍 Calling onFocusChanged callback...")
onFocusChanged?.invoke(hasFocus)
android.util.Log.d("AppleEmojiTextField", " ✅ onFocusChanged callback completed")
}
// Уведомляем о создании view
onViewCreated?.invoke(this)
}

View File

@@ -45,15 +45,15 @@ import kotlinx.coroutines.launch
* 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout)
* 3. Hardware layer для анимаций
* 4. Минимум recomposition (derivedStateOf, remember keys)
* 5. Smooth slide + fade transitions (Telegram-style)
* 6. Coil оптимизация (hardware acceleration, size limits)
* 7. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
* 8. keyboardDuration для синхронизации с системной клавиатурой
* 5. Coil оптимизация (hardware acceleration, size limits)
* 6. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
* 7. keyboardDuration для синхронизации с системной клавиатурой
* 8. Анимация управляется внешним AnimatedKeyboardTransition
*
* @param isVisible Видимость панели
* @param isVisible Видимость панели (для внутренней логики)
* @param isDarkTheme Темная/светлая тема
* @param onEmojiSelected Callback при выборе эмодзи
* @param onClose Callback при закрытии (не используется, панель просто скрывается)
* @param onClose Callback при закрытии
* @param modifier Модификатор
*/
@OptIn(ExperimentalAnimationApi::class)
@@ -68,59 +68,19 @@ fun OptimizedEmojiPicker(
// 🔥 Используем сохранённую высоту клавиатуры (как в 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)")
android.util.Log.d("EmojiPicker", "🎭 OptimizedEmojiPicker visibility: $isVisible (height=${savedKeyboardHeight})")
}
// 🎭 Telegram-style анимация: используем сохранённую длительность
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(
durationMillis = animationDuration, // 🔥 Telegram's 250ms
easing = FastOutSlowInEasing
)
) + fadeIn(
animationSpec = tween(
durationMillis = animationDuration / 2,
easing = LinearEasing
)
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(
durationMillis = (animationDuration * 0.8).toInt(), // 🔥 Быстрое закрытие (200ms)
easing = FastOutLinearInEasing
)
) + fadeOut(
animationSpec = tween(
durationMillis = (animationDuration * 0.6).toInt(),
easing = LinearEasing
)
),
// 🔥 Рендерим контент напрямую без AnimatedVisibility
// Анимация теперь управляется AnimatedKeyboardTransition
EmojiPickerContent(
isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected,
keyboardHeight = savedKeyboardHeight,
modifier = modifier
) {
// 🎨 Hardware layer для анимаций (GPU ускорение - как в Telegram)
Box(
modifier = Modifier.graphicsLayer {
// Используем hardware layer только во время анимации
if (transition.isRunning) {
this.alpha = 1f
}
}
) {
EmojiPickerContent(
isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected,
keyboardHeight = savedKeyboardHeight
)
}
}
)
}
/**
@@ -130,7 +90,8 @@ fun OptimizedEmojiPicker(
private fun EmojiPickerContent(
isDarkTheme: Boolean,
onEmojiSelected: (String) -> Unit,
keyboardHeight: Dp
keyboardHeight: Dp,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
@@ -185,7 +146,7 @@ private fun EmojiPickerContent(
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
Column(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram)
.background(panelBackground)