- 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.
19 KiB
19 KiB
🎯 План плавной смены клавиатур (Telegram-style)
📊 Анализ проблемы
Текущая ситуация:
- ✅ Высота клавиатур одинаковая (364.95dp)
- ❌ UI дергается при переключении клавиатур
- ❌ Нет синхронизации анимаций
- ❌ Emoji панель появляется/исчезает резко
Как работает Telegram:
Ключевые компоненты:
- AdjustPanLayoutHelper - координирует анимации клавиатуры
- EditTextEmoji.showPopup() - управляет переключением
- ValueAnimator - анимирует translationY
- 250ms duration + keyboardInterpolator (FastOutSlowIn)
Важные паттерны Telegram:
// 1. Синхронная анимация - emoji панель двигается вместе с keyboard
animator.setDuration(AdjustPanLayoutHelper.keyboardDuration); // 250ms
animator.setInterpolator(AdjustPanLayoutHelper.keyboardInterpolator);
// 2. TranslationY вместо изменения height
emojiView.setTranslationY(v); // плавно двигаем панель
// 3. Одновременное скрытие keyboard и показ emoji
if (!keyboardVisible && !emojiWasVisible) {
// анимируем появление emoji снизу
ValueAnimator animator = ValueAnimator.ofFloat(emojiPadding, 0);
}
🎬 Архитектура решения для Compose
Phase 1: Механизм синхронизации анимаций
1.1 KeyboardTransitionCoordinator
Цель: Координировать переходы между клавиатурами
@Composable
fun rememberKeyboardTransitionCoordinator(): KeyboardTransitionCoordinator {
return remember {
KeyboardTransitionCoordinator()
}
}
class KeyboardTransitionCoordinator {
// Состояния перехода
enum class TransitionState {
IDLE, // Ничего не происходит
KEYBOARD_TO_EMOJI, // Keyboard → Emoji
EMOJI_TO_KEYBOARD, // Emoji → Keyboard
KEYBOARD_OPENING, // Только keyboard открывается
EMOJI_OPENING, // Только emoji открывается
KEYBOARD_CLOSING, // Только keyboard закрывается
EMOJI_CLOSING // Только emoji закрывается
}
var currentState by mutableStateOf(TransitionState.IDLE)
var transitionProgress by mutableFloatStateOf(0f)
// Высоты для расчетов
var keyboardHeight by mutableStateOf(0.dp)
var emojiHeight by mutableStateOf(0.dp)
// Флаги
var isKeyboardVisible by mutableStateOf(false)
var isEmojiVisible by mutableStateOf(false)
// Запуск перехода
fun startTransition(
from: TransitionState,
to: TransitionState,
onComplete: () -> Unit
) {
currentState = to
// Логика анимации
}
}
1.2 Compose Animatable для плавности
val emojiOffsetY = remember { Animatable(0f) }
val keyboardOffsetY = remember { Animatable(0f) }
// Telegram-style timing
val transitionSpec = tween<Float>(
durationMillis = 250,
easing = FastOutSlowInEasing // keyboardInterpolator
)
Phase 2: Управление состояниями
2.1 State Machine для переходов
sealed class KeyboardState {
object Closed : KeyboardState()
object SystemKeyboardOpen : KeyboardState()
object EmojiKeyboardOpen : KeyboardState()
data class Transitioning(
val from: KeyboardState,
val to: KeyboardState,
val progress: Float
) : KeyboardState()
}
class KeyboardStateManager {
var currentState by mutableStateOf<KeyboardState>(KeyboardState.Closed)
fun transition(target: KeyboardState) {
when (currentState to target) {
KeyboardState.SystemKeyboardOpen to KeyboardState.EmojiKeyboardOpen -> {
// Keyboard → Emoji
startKeyboardToEmojiTransition()
}
KeyboardState.EmojiKeyboardOpen to KeyboardState.SystemKeyboardOpen -> {
// Emoji → Keyboard
startEmojiToKeyboardTransition()
}
// ... другие переходы
}
}
}
Phase 3: Анимация переходов
3.1 Keyboard → Emoji (самый сложный)
Проблема: Системная клавиатура закрывается асинхронно
Решение Telegram:
- Не ждать закрытия клавиатуры
- Сразу показать emoji панель на месте клавиатуры
- Анимировать emoji снизу вверх, пока keyboard уходит
@Composable
fun KeyboardToEmojiTransition(
coordinator: KeyboardTransitionCoordinator,
onComplete: () -> Unit
) {
val offsetY = remember { Animatable(coordinator.keyboardHeight.value) }
LaunchedEffect(Unit) {
// Шаг 1: Скрыть клавиатуру (асинхронно)
coordinator.hideSystemKeyboard()
// Шаг 2: Сразу анимировать emoji снизу (250ms)
offsetY.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
)
onComplete()
}
// Emoji панель с offset
Box(
modifier = Modifier
.fillMaxWidth()
.height(coordinator.emojiHeight)
.offset(y = offsetY.value.dp)
) {
OptimizedEmojiPicker(...)
}
}
3.2 Emoji → Keyboard
@Composable
fun EmojiToKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
onComplete: () -> Unit
) {
val emojiOffsetY = remember { Animatable(0f) }
LaunchedEffect(Unit) {
// Шаг 1: Показать клавиатуру (асинхронно)
coordinator.showSystemKeyboard()
// Шаг 2: Одновременно анимировать emoji вниз (250ms)
emojiOffsetY.animateTo(
targetValue = coordinator.emojiHeight.value,
animationSpec = tween(
durationMillis = 250,
easing = FastOutLinearInEasing // быстрее уходит
)
)
onComplete()
}
// Emoji панель уходит вниз
Box(
modifier = Modifier
.fillMaxWidth()
.height(coordinator.emojiHeight)
.offset(y = emojiOffsetY.value.dp)
.alpha(1f - (emojiOffsetY.value / coordinator.emojiHeight.value))
) {
OptimizedEmojiPicker(...)
}
}
Phase 4: Интеграция с IME
4.1 WindowInsets синхронизация
@Composable
fun MessageInputBar() {
val coordinator = rememberKeyboardTransitionCoordinator()
val ime = WindowInsets.ime
val imeHeight = ime.getBottom(LocalDensity.current).toDp()
// Отслеживаем изменения IME
LaunchedEffect(imeHeight) {
coordinator.keyboardHeight = imeHeight
// Если IME закрылась во время показа emoji
if (imeHeight == 0.dp && coordinator.isEmojiVisible) {
// Продолжить показ emoji без дерганья
coordinator.currentState = TransitionState.IDLE
}
}
}
4.2 Spacer для резервации места
Ключевая идея Telegram: Emoji панель резервирует место клавиатуры
@Composable
fun KeyboardSpacer(
coordinator: KeyboardTransitionCoordinator
) {
val height by animateDpAsState(
targetValue = when {
coordinator.isKeyboardVisible -> coordinator.keyboardHeight
coordinator.isEmojiVisible -> coordinator.emojiHeight
else -> 0.dp
},
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
)
Spacer(modifier = Modifier.height(height))
}
🔧 Детальная имплементация
Шаг 1: KeyboardTransitionCoordinator.kt
class KeyboardTransitionCoordinator(
private val context: Context
) {
companion object {
const val TRANSITION_DURATION = 250L
val TRANSITION_EASING = FastOutSlowInEasing
}
// Состояние
var keyboardHeight by mutableStateOf(0.dp)
var emojiHeight by mutableStateOf(0.dp)
var isKeyboardVisible by mutableStateOf(false)
var isEmojiVisible by mutableStateOf(false)
var isTransitioning by mutableStateOf(false)
// Для отладки
private val tag = "KeyboardTransition"
fun requestShowEmoji(
hideKeyboard: () -> Unit,
showEmoji: () -> Unit
) {
Log.d(tag, "🔄 Keyboard → Emoji transition started")
isTransitioning = true
// Telegram паттерн: сначала скрыть клавиатуру
hideKeyboard()
// Через небольшую задержку показать emoji
// (даем системе начать закрытие клавиатуры)
Handler(Looper.getMainLooper()).postDelayed({
showEmoji()
isEmojiVisible = true
isKeyboardVisible = false
isTransitioning = false
Log.d(tag, "✅ Emoji visible, keyboard hidden")
}, 50L)
}
fun requestShowKeyboard(
showKeyboard: () -> Unit,
hideEmoji: () -> Unit
) {
Log.d(tag, "🔄 Emoji → Keyboard transition started")
isTransitioning = true
// Сначала показать клавиатуру
showKeyboard()
// Emoji скрыть после начала анимации
Handler(Looper.getMainLooper()).postDelayed({
hideEmoji()
isEmojiVisible = false
isKeyboardVisible = true
isTransitioning = false
Log.d(tag, "✅ Keyboard visible, emoji hidden")
}, 50L)
}
}
Шаг 2: Обновить MessageInputBar
@Composable
fun MessageInputBar(...) {
val coordinator = rememberKeyboardTransitionCoordinator()
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
// Отслеживание IME
val ime = WindowInsets.ime
val imeHeight = with(LocalDensity.current) {
ime.getBottom(this).toDp()
}
// Обновляем высоту клавиатуры
LaunchedEffect(imeHeight) {
if (imeHeight > 100.dp) {
coordinator.keyboardHeight = imeHeight
coordinator.isKeyboardVisible = true
} else if (imeHeight == 0.dp && !coordinator.isEmojiVisible) {
coordinator.isKeyboardVisible = false
}
Log.d("KeyboardHeight", "IME height: $imeHeight")
}
Column {
// Input field
Row {
TextField(...)
// Emoji button
IconButton(
onClick = {
if (coordinator.isEmojiVisible) {
// Переключить на клавиатуру
coordinator.requestShowKeyboard(
showKeyboard = {
focusRequester.requestFocus()
keyboardController?.show()
},
hideEmoji = {
showEmojiPicker = false
}
)
} else {
// Переключить на emoji
coordinator.requestShowEmoji(
hideKeyboard = {
keyboardController?.hide()
},
showEmoji = {
showEmojiPicker = true
coordinator.emojiHeight = coordinator.keyboardHeight
}
)
}
}
) {
Icon(
imageVector = if (coordinator.isEmojiVisible) {
Icons.Default.Keyboard
} else {
Icons.Default.EmojiEmotions
}
)
}
}
// Emoji picker с анимацией
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker
)
}
}
Шаг 3: AnimatedKeyboardTransition.kt
@Composable
fun AnimatedKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
showEmojiPicker: Boolean
) {
val offsetY = remember { Animatable(0f) }
val alpha = remember { Animatable(0f) }
LaunchedEffect(showEmojiPicker) {
if (showEmojiPicker) {
// Показать emoji
launch {
offsetY.snapTo(coordinator.emojiHeight.value)
offsetY.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
)
}
launch {
alpha.snapTo(0f)
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 200)
)
}
} else {
// Скрыть emoji
launch {
offsetY.animateTo(
targetValue = coordinator.emojiHeight.value,
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
}
launch {
alpha.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 150)
)
}
}
}
if (showEmojiPicker || offsetY.value > 0f) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(coordinator.emojiHeight)
.offset(y = offsetY.value.dp)
.alpha(alpha.value)
) {
OptimizedEmojiPicker(
keyboardHeight = coordinator.emojiHeight,
...
)
}
}
}
📈 Этапы внедрения
Этап 1: Базовая инфраструктура (1-2 часа)
- Создать
KeyboardTransitionCoordinator.kt - Добавить state management для переходов
- Настроить логирование для отладки
Этап 2: Анимации (2-3 часа)
- Создать
AnimatedKeyboardTransition.kt - Реализовать Keyboard → Emoji переход
- Реализовать Emoji → Keyboard переход
- Добавить fade анимацию для плавности
Этап 3: Интеграция (1-2 часа)
- Обновить
MessageInputBarс coordinator - Интегрировать с
OptimizedEmojiPicker - Добавить
KeyboardSpacerдля резервации места
Этап 4: Полировка (1-2 часа)
- Синхронизация с IME events
- Обработка edge cases (поворот экрана, многозадачность)
- Убрать дерганья при быстрых переключениях
- Тестирование на разных устройствах
Этап 5: Оптимизация (1 час)
- Убрать избыточные recomposition
- Добавить
rememberгде нужно - Проверить производительность
- Удалить debug логи (опционально)
🎨 Ключевые паттерны Telegram
1. Немедленное резервирование места
// Telegram сразу резервирует место для emoji панели
emojiPadding = currentHeight
sizeNotifierLayout.requestLayout()
2. TranslationY вместо show/hide
// Анимация позиции, а не visibility
emojiView.setTranslationY(v)
3. Синхронные анимации
// Все анимации используют одинаковый timing
animator.setDuration(AdjustPanLayoutHelper.keyboardDuration); // 250ms
animator.setInterpolator(AdjustPanLayoutHelper.keyboardInterpolator);
4. Плавный alpha для emoji
// Fade in/out для естественности
emojiViewAlpha = 1f - v / (float) emojiPadding;
emojiView.setAlpha(emojiViewAlpha);
🐛 Возможные проблемы и решения
Проблема 1: IME закрывается слишком медленно
Решение: Не ждать закрытия, показать emoji сразу
// Telegram паттерн
if (!keyboardVisible && !emojiWasVisible) {
// Анимировать emoji снизу, пока keyboard закрывается
}
Проблема 2: UI дергается при переключении
Решение: Резервировать место спейсером
Spacer(modifier = Modifier.height(max(keyboardHeight, emojiHeight)))
Проблема 3: Разные высоты на landscape/portrait
Решение: Сохранять отдельно для каждой ориентации
val keyboardHeight = if (isLandscape) keyboardHeightLand else keyboardHeightPort
Проблема 4: Быстрые переключения
Решение: Отменять предыдущие анимации
LaunchedEffect(showEmojiPicker) {
offsetY.stop() // Остановить текущую анимацию
// Начать новую
}
📊 Ожидаемый результат
До:
- ❌ UI дергается
- ❌ Резкое появление/исчезновение
- ❌ Несинхронные анимации
- ❌ Пустое место при переключении
После:
- ✅ Плавный переход за 250ms
- ✅ Синхронизированные анимации
- ✅ Telegram-style UX
- ✅ Нет дерганий UI
- ✅ Резервированное место
🚀 Готовы начинать?
План готов! Начнем с создания KeyboardTransitionCoordinator.kt - это основа всей системы. После этого добавим анимации и интегрируем в MessageInputBar.
Telegram решает эту задачу через:
- ValueAnimator с 250ms и FastOutSlowIn
- TranslationY для плавного движения
- Немедленное резервирование места
- Синхронизация всех анимаций
Готов приступить к реализации! 🎯