622 lines
19 KiB
Markdown
622 lines
19 KiB
Markdown
# 🎯 План плавной смены клавиатур (Telegram-style)
|
||
|
||
## 📊 Анализ проблемы
|
||
|
||
### Текущая ситуация:
|
||
|
||
- ✅ Высота клавиатур одинаковая (364.95dp)
|
||
- ❌ UI дергается при переключении клавиатур
|
||
- ❌ Нет синхронизации анимаций
|
||
- ❌ Emoji панель появляется/исчезает резко
|
||
|
||
### Как работает Telegram:
|
||
|
||
**Ключевые компоненты:**
|
||
|
||
1. **AdjustPanLayoutHelper** - координирует анимации клавиатуры
|
||
2. **EditTextEmoji.showPopup()** - управляет переключением
|
||
3. **ValueAnimator** - анимирует translationY
|
||
4. **250ms duration + keyboardInterpolator** (FastOutSlowIn)
|
||
|
||
**Важные паттерны Telegram:**
|
||
|
||
```java
|
||
// 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
|
||
|
||
**Цель:** Координировать переходы между клавиатурами
|
||
|
||
```kotlin
|
||
@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 для плавности
|
||
|
||
```kotlin
|
||
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 для переходов
|
||
|
||
```kotlin
|
||
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 уходит**
|
||
|
||
```kotlin
|
||
@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
|
||
|
||
```kotlin
|
||
@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 синхронизация
|
||
|
||
```kotlin
|
||
@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 панель резервирует место клавиатуры
|
||
|
||
```kotlin
|
||
@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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
@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
|
||
|
||
```kotlin
|
||
@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. **Немедленное резервирование места**
|
||
|
||
```kotlin
|
||
// Telegram сразу резервирует место для emoji панели
|
||
emojiPadding = currentHeight
|
||
sizeNotifierLayout.requestLayout()
|
||
```
|
||
|
||
### 2. **TranslationY вместо show/hide**
|
||
|
||
```kotlin
|
||
// Анимация позиции, а не visibility
|
||
emojiView.setTranslationY(v)
|
||
```
|
||
|
||
### 3. **Синхронные анимации**
|
||
|
||
```kotlin
|
||
// Все анимации используют одинаковый timing
|
||
animator.setDuration(AdjustPanLayoutHelper.keyboardDuration); // 250ms
|
||
animator.setInterpolator(AdjustPanLayoutHelper.keyboardInterpolator);
|
||
```
|
||
|
||
### 4. **Плавный alpha для emoji**
|
||
|
||
```kotlin
|
||
// Fade in/out для естественности
|
||
emojiViewAlpha = 1f - v / (float) emojiPadding;
|
||
emojiView.setAlpha(emojiViewAlpha);
|
||
```
|
||
|
||
## 🐛 Возможные проблемы и решения
|
||
|
||
### Проблема 1: IME закрывается слишком медленно
|
||
|
||
**Решение:** Не ждать закрытия, показать emoji сразу
|
||
|
||
```kotlin
|
||
// Telegram паттерн
|
||
if (!keyboardVisible && !emojiWasVisible) {
|
||
// Анимировать emoji снизу, пока keyboard закрывается
|
||
}
|
||
```
|
||
|
||
### Проблема 2: UI дергается при переключении
|
||
|
||
**Решение:** Резервировать место спейсером
|
||
|
||
```kotlin
|
||
Spacer(modifier = Modifier.height(max(keyboardHeight, emojiHeight)))
|
||
```
|
||
|
||
### Проблема 3: Разные высоты на landscape/portrait
|
||
|
||
**Решение:** Сохранять отдельно для каждой ориентации
|
||
|
||
```kotlin
|
||
val keyboardHeight = if (isLandscape) keyboardHeightLand else keyboardHeightPort
|
||
```
|
||
|
||
### Проблема 4: Быстрые переключения
|
||
|
||
**Решение:** Отменять предыдущие анимации
|
||
|
||
```kotlin
|
||
LaunchedEffect(showEmojiPicker) {
|
||
offsetY.stop() // Остановить текущую анимацию
|
||
// Начать новую
|
||
}
|
||
```
|
||
|
||
## 📊 Ожидаемый результат
|
||
|
||
### До:
|
||
|
||
- ❌ UI дергается
|
||
- ❌ Резкое появление/исчезновение
|
||
- ❌ Несинхронные анимации
|
||
- ❌ Пустое место при переключении
|
||
|
||
### После:
|
||
|
||
- ✅ Плавный переход за 250ms
|
||
- ✅ Синхронизированные анимации
|
||
- ✅ Telegram-style UX
|
||
- ✅ Нет дерганий UI
|
||
- ✅ Резервированное место
|
||
|
||
## 🚀 Готовы начинать?
|
||
|
||
План готов! Начнем с создания `KeyboardTransitionCoordinator.kt` - это основа всей системы. После этого добавим анимации и интегрируем в `MessageInputBar`.
|
||
|
||
Telegram решает эту задачу через:
|
||
|
||
1. **ValueAnimator** с 250ms и FastOutSlowIn
|
||
2. **TranslationY** для плавного движения
|
||
3. **Немедленное резервирование места**
|
||
4. **Синхронизация всех анимаций**
|
||
|
||
Готов приступить к реализации! 🎯
|