Files
mobile-android/docs/SMOOTH_KEYBOARD_TRANSITION_PLAN.md

19 KiB
Raw Permalink Blame History

🎯 План плавной смены клавиатур (Telegram-style)

📊 Анализ проблемы

Текущая ситуация:

  • Высота клавиатур одинаковая (364.95dp)
  • UI дергается при переключении клавиатур
  • Нет синхронизации анимаций
  • Emoji панель появляется/исчезает резко

Как работает Telegram:

Ключевые компоненты:

  1. AdjustPanLayoutHelper - координирует анимации клавиатуры
  2. EditTextEmoji.showPopup() - управляет переключением
  3. ValueAnimator - анимирует translationY
  4. 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:

  1. Не ждать закрытия клавиатуры
  2. Сразу показать emoji панель на месте клавиатуры
  3. Анимировать 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 решает эту задачу через:

  1. ValueAnimator с 250ms и FastOutSlowIn
  2. TranslationY для плавного движения
  3. Немедленное резервирование места
  4. Синхронизация всех анимаций

Готов приступить к реализации! 🎯