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:
621
SMOOTH_KEYBOARD_TRANSITION_PLAN.md
Normal file
621
SMOOTH_KEYBOARD_TRANSITION_PLAN.md
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
# 🎯 План плавной смены клавиатур (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. **Синхронизация всех анимаций**
|
||||||
|
|
||||||
|
Готов приступить к реализации! 🎯
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package app.rosette.android.ui.keyboard
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анимированный контейнер для emoji панели с Telegram-style переходами.
|
||||||
|
*
|
||||||
|
* Паттерны Telegram:
|
||||||
|
* - Keyboard → Emoji: панель выезжает снизу за 250ms (FastOutSlowIn)
|
||||||
|
* - Emoji → Keyboard: панель уезжает вниз за 200ms (FastOutLinearIn)
|
||||||
|
* - TranslationY + Alpha для плавности
|
||||||
|
* - Немедленное резервирование места
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AnimatedKeyboardTransition(
|
||||||
|
coordinator: KeyboardTransitionCoordinator,
|
||||||
|
showEmojiPicker: Boolean,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val tag = "AnimatedTransition"
|
||||||
|
|
||||||
|
// Animatable для плавной анимации offset
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val offsetY = remember { Animatable(0f) }
|
||||||
|
val alpha = remember { Animatable(1f) }
|
||||||
|
|
||||||
|
// Отслеживаем изменения showEmojiPicker
|
||||||
|
LaunchedEffect(showEmojiPicker) {
|
||||||
|
Log.d(tag, "════════════════════════════════════════════════════════")
|
||||||
|
Log.d(tag, "🎬 Animation triggered: showEmojiPicker=$showEmojiPicker")
|
||||||
|
Log.d(tag, "📊 Current state: offsetY=${offsetY.value}dp, alpha=${alpha.value}, emojiHeight=${coordinator.emojiHeight.value}dp")
|
||||||
|
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
// ============ ПОКАЗАТЬ EMOJI ============
|
||||||
|
|
||||||
|
val height = coordinator.emojiHeight.value
|
||||||
|
Log.d(tag, "📤 Animating emoji IN from ${height}dp to 0dp")
|
||||||
|
Log.d(tag, "⏱️ Duration: ${KeyboardTransitionCoordinator.TRANSITION_DURATION}ms, Easing: FastOutSlowIn")
|
||||||
|
|
||||||
|
// Начальная позиция: внизу за экраном
|
||||||
|
Log.d(tag, "🎯 Setting initial offset to ${height}dp (off-screen)")
|
||||||
|
offsetY.snapTo(height)
|
||||||
|
scope.launch {
|
||||||
|
offsetY.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = KeyboardTransitionCoordinator.TRANSITION_DURATION.toInt(),
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(tag, "✅ Emoji slide animation completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in (немного быстрее)
|
||||||
|
Log.d(tag, "✨ Starting fade in animation (200ms)")
|
||||||
|
scope.launch {
|
||||||
|
alpha.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(durationMillis = 200)
|
||||||
|
)
|
||||||
|
Log.d(tag, "✅ Fade in completed, alpha=${alpha.value}")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (offsetY.value < coordinator.emojiHeight.value * 0.9f) {
|
||||||
|
// ============ СКРЫТЬ EMOJI ============
|
||||||
|
Log.d(tag, "📥 Hiding emoji panel (offsetY=${offsetY.value}dp < threshold=${coordinator.emojiHeight.value * 0.9f}dp)")
|
||||||
|
|
||||||
|
val height = coordinator.emojiHeight.value
|
||||||
|
Log.d(tag, "🎯 Animating emoji OUT from ${offsetY.value}dp to ${height}dp")
|
||||||
|
Log.d(tag, "⏱️ Duration: 200ms, Easing: FastOutLinearIn")
|
||||||
|
scope.launch {
|
||||||
|
offsetY.animateTo(
|
||||||
|
targetValue = height,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutLinearInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(tag, "✅ Emoji hide animation completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out (быстрее)
|
||||||
|
Log.d(tag, "🌑 Starting fade out animation (150ms)")
|
||||||
|
scope.launch {
|
||||||
|
alpha.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(durationMillis = 150)
|
||||||
|
)
|
||||||
|
Log.d(tag, "✅ Fade out completed, alpha=${alpha.value}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Рендерим контент ТОЛЬКО если showEmojiPicker = true
|
||||||
|
// Не проверяем offsetY, чтобы избежать показа emoji при открытии клавиатуры
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
Log.d(tag, "✅ Rendering emoji content (showEmojiPicker=true)")
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(coordinator.emojiHeight)
|
||||||
|
.offset(y = offsetY.value.dp)
|
||||||
|
.alpha(alpha.value)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Упрощенная версия без fade анимации.
|
||||||
|
* Только slide (как в оригинальном Telegram).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SimpleAnimatedKeyboardTransition(
|
||||||
|
coordinator: KeyboardTransitionCoordinator,
|
||||||
|
showEmojiPicker: Boolean,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val offsetY = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(showEmojiPicker) {
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
// Показать: снизу вверх
|
||||||
|
offsetY.snapTo(coordinator.emojiHeight.value)
|
||||||
|
offsetY.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = KeyboardTransitionCoordinator.TRANSITION_DURATION.toInt(),
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Скрыть: сверху вниз
|
||||||
|
offsetY.animateTo(
|
||||||
|
targetValue = coordinator.emojiHeight.value,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutLinearInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEmojiPicker || offsetY.value < coordinator.emojiHeight.value * 0.95f) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(coordinator.emojiHeight)
|
||||||
|
.offset(y = offsetY.value.dp)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package app.rosette.android.ui.keyboard
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Координатор переходов между системной клавиатурой и emoji панелью.
|
||||||
|
* Реализует Telegram-style плавные анимации.
|
||||||
|
*
|
||||||
|
* Ключевые принципы:
|
||||||
|
* - 250ms duration (как в Telegram AdjustPanLayoutHelper)
|
||||||
|
* - Немедленное резервирование места
|
||||||
|
* - TranslationY анимации вместо show/hide
|
||||||
|
* - Синхронизация всех переходов
|
||||||
|
*/
|
||||||
|
class KeyboardTransitionCoordinator {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TRANSITION_DURATION = 250L
|
||||||
|
const val SHORT_DELAY = 50L
|
||||||
|
private const val TAG = "KeyboardTransition"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Состояния переходов ============
|
||||||
|
|
||||||
|
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)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var transitionProgress by mutableFloatStateOf(0f)
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ============ Высоты ============
|
||||||
|
|
||||||
|
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 set
|
||||||
|
|
||||||
|
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
||||||
|
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
|
// ============ Главный метод: Keyboard → Emoji ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переход от системной клавиатуры к emoji панели.
|
||||||
|
* Telegram паттерн: сначала скрыть клавиатуру, затем показать emoji.
|
||||||
|
*/
|
||||||
|
fun requestShowEmoji(
|
||||||
|
hideKeyboard: () -> Unit,
|
||||||
|
showEmoji: () -> Unit
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "═══════════════════════════════════════════════════════")
|
||||||
|
Log.d(TAG, "📱 requestShowEmoji() START")
|
||||||
|
Log.d(TAG, "🔄 Keyboard → Emoji transition started")
|
||||||
|
Log.d(TAG, " 📊 Current state:")
|
||||||
|
Log.d(TAG, " - currentState=$currentState")
|
||||||
|
Log.d(TAG, " - keyboardHeight=$keyboardHeight, emojiHeight=$emojiHeight")
|
||||||
|
Log.d(TAG, " - isTransitioning=$isTransitioning")
|
||||||
|
Log.d(TAG, " - isKeyboardVisible=$isKeyboardVisible, isEmojiVisible=$isEmojiVisible")
|
||||||
|
Log.d(TAG, " - pendingShowEmojiCallback=${if (pendingShowEmojiCallback != null) "EXISTS" else "null"}")
|
||||||
|
|
||||||
|
Log.d(TAG, " 📝 Setting currentState = KEYBOARD_TO_EMOJI")
|
||||||
|
currentState = TransitionState.KEYBOARD_TO_EMOJI
|
||||||
|
isTransitioning = true
|
||||||
|
|
||||||
|
// 🔥 Сохраняем коллбэк для вызова после закрытия клавиатуры
|
||||||
|
Log.d(TAG, " 💾 Saving pending emoji callback...")
|
||||||
|
pendingShowEmojiCallback = {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "🎬 Executing pending emoji show callback")
|
||||||
|
Log.d(TAG, " Current keyboard height at execution: $keyboardHeight")
|
||||||
|
Log.d(TAG, " 📞 Calling showEmoji()...")
|
||||||
|
showEmoji()
|
||||||
|
Log.d(TAG, " 📞 Setting isEmojiVisible = true")
|
||||||
|
isEmojiVisible = true
|
||||||
|
isKeyboardVisible = false
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
pendingShowEmojiCallback = null
|
||||||
|
Log.d(TAG, "✅ Emoji shown via pending callback")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Error in pendingShowEmojiCallback", e)
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
pendingShowEmojiCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, " ✅ Pending callback saved")
|
||||||
|
|
||||||
|
// Шаг 1: Скрыть системную клавиатуру
|
||||||
|
Log.d(TAG, " 📞 Calling hideKeyboard()...")
|
||||||
|
try {
|
||||||
|
hideKeyboard()
|
||||||
|
Log.d(TAG, " ✅ hideKeyboard() completed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Error hiding keyboard", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если клавиатура уже закрыта (height = 0), показываем emoji сразу
|
||||||
|
if (keyboardHeight <= 0.dp) {
|
||||||
|
Log.d(TAG, "⚡ Keyboard already closed, showing emoji immediately")
|
||||||
|
pendingShowEmojiCallback?.invoke()
|
||||||
|
}
|
||||||
|
// Иначе ждем когда updateKeyboardHeight получит 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Главный метод: Emoji → Keyboard ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переход от emoji панели к системной клавиатуре.
|
||||||
|
* Telegram паттерн: показать клавиатуру и плавно скрыть emoji.
|
||||||
|
*/
|
||||||
|
fun requestShowKeyboard(
|
||||||
|
showKeyboard: () -> Unit,
|
||||||
|
hideEmoji: () -> Unit
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "═══════════════════════════════════════════════════════")
|
||||||
|
Log.d(TAG, "⌨️ requestShowKeyboard() START")
|
||||||
|
Log.d(TAG, "🔄 Emoji → Keyboard transition started")
|
||||||
|
Log.d(TAG, " 📊 Current state:")
|
||||||
|
Log.d(TAG, " - currentState=$currentState")
|
||||||
|
Log.d(TAG, " - keyboardHeight=$keyboardHeight, emojiHeight=$emojiHeight")
|
||||||
|
Log.d(TAG, " - isTransitioning=$isTransitioning")
|
||||||
|
Log.d(TAG, " - isKeyboardVisible=$isKeyboardVisible, isEmojiVisible=$isEmojiVisible")
|
||||||
|
Log.d(TAG, " - pendingShowEmojiCallback=${if (pendingShowEmojiCallback != null) "EXISTS" else "null"}")
|
||||||
|
|
||||||
|
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
||||||
|
if (pendingShowEmojiCallback != null) {
|
||||||
|
Log.d(TAG, "⚠️ Cancelling pending emoji callback (switching to keyboard)")
|
||||||
|
pendingShowEmojiCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, " 📞 Setting currentState = EMOJI_TO_KEYBOARD")
|
||||||
|
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||||
|
isTransitioning = true
|
||||||
|
|
||||||
|
// Шаг 1: Показать системную клавиатуру
|
||||||
|
Log.d(TAG, " 📞 Calling showKeyboard()...")
|
||||||
|
try {
|
||||||
|
showKeyboard()
|
||||||
|
Log.d(TAG, " ✅ showKeyboard() completed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Error showing keyboard", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: Через небольшую задержку скрыть emoji
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
try {
|
||||||
|
hideEmoji()
|
||||||
|
isEmojiVisible = false
|
||||||
|
isKeyboardVisible = true
|
||||||
|
|
||||||
|
// Через время анимации завершаем переход
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
Log.d(TAG, "✅ Keyboard visible, emoji hidden")
|
||||||
|
}, TRANSITION_DURATION)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Error in requestShowKeyboard transition", e)
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
}
|
||||||
|
}, SHORT_DELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Простые переходы ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть только emoji панель (без клавиатуры).
|
||||||
|
*/
|
||||||
|
fun openEmojiOnly(showEmoji: () -> Unit) {
|
||||||
|
Log.d(TAG, "😊 Opening emoji panel only")
|
||||||
|
|
||||||
|
currentState = TransitionState.EMOJI_OPENING
|
||||||
|
isTransitioning = true
|
||||||
|
|
||||||
|
// Установить высоту emoji равной сохраненной высоте клавиатуры
|
||||||
|
if (emojiHeight == 0.dp && keyboardHeight > 0.dp) {
|
||||||
|
emojiHeight = keyboardHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
showEmoji()
|
||||||
|
isEmojiVisible = true
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
Log.d(TAG, "✅ Emoji panel opened")
|
||||||
|
}, TRANSITION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть emoji панель.
|
||||||
|
*/
|
||||||
|
fun closeEmoji(hideEmoji: () -> Unit) {
|
||||||
|
Log.d(TAG, "😊 Closing emoji panel")
|
||||||
|
|
||||||
|
currentState = TransitionState.EMOJI_CLOSING
|
||||||
|
isTransitioning = true
|
||||||
|
|
||||||
|
hideEmoji()
|
||||||
|
isEmojiVisible = false
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
Log.d(TAG, "✅ Emoji panel closed")
|
||||||
|
}, TRANSITION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть системную клавиатуру.
|
||||||
|
*/
|
||||||
|
fun closeKeyboard(hideKeyboard: () -> Unit) {
|
||||||
|
Log.d(TAG, "⌨️ Closing keyboard")
|
||||||
|
|
||||||
|
currentState = TransitionState.KEYBOARD_CLOSING
|
||||||
|
isTransitioning = true
|
||||||
|
|
||||||
|
hideKeyboard()
|
||||||
|
isKeyboardVisible = false
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
Log.d(TAG, "✅ Keyboard closed")
|
||||||
|
}, TRANSITION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Вспомогательные методы ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить высоту клавиатуры из IME.
|
||||||
|
*/
|
||||||
|
fun updateKeyboardHeight(height: Dp) {
|
||||||
|
Log.d(TAG, "════════════════════════════════════════════════════════")
|
||||||
|
Log.d(TAG, "📏 updateKeyboardHeight called: $keyboardHeight → $height")
|
||||||
|
Log.d(TAG, " isKeyboardVisible=$isKeyboardVisible, emojiHeight=$emojiHeight")
|
||||||
|
|
||||||
|
if (height > 100.dp && height != keyboardHeight) {
|
||||||
|
Log.d(TAG, "✅ Keyboard height updated: $keyboardHeight → $height")
|
||||||
|
keyboardHeight = height
|
||||||
|
|
||||||
|
// Если emoji высота не установлена, синхронизировать
|
||||||
|
if (emojiHeight == 0.dp) {
|
||||||
|
Log.d(TAG, "🔄 Syncing emoji height (was 0): $emojiHeight → $height")
|
||||||
|
emojiHeight = height
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "⏭️ No update needed (height too small or unchanged)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Если клавиатура закрылась И есть pending коллбэк → показываем emoji
|
||||||
|
if (height == 0.dp && pendingShowEmojiCallback != null) {
|
||||||
|
Log.d(TAG, "🎯 Keyboard closed (0dp), triggering pending emoji callback")
|
||||||
|
val callback = pendingShowEmojiCallback
|
||||||
|
pendingShowEmojiCallback = null // 🔥 Сразу обнуляем чтобы не вызвать дважды
|
||||||
|
|
||||||
|
// Вызываем через небольшую задержку чтобы UI стабилизировался
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
try {
|
||||||
|
callback?.invoke()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Error invoking pending emoji callback", e)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить высоту emoji панели.
|
||||||
|
*/
|
||||||
|
fun updateEmojiHeight(height: Dp) {
|
||||||
|
if (height > 0.dp && height != emojiHeight) {
|
||||||
|
Log.d(TAG, "📏 Emoji height updated: $emojiHeight → $height")
|
||||||
|
emojiHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизировать высоты (emoji = keyboard).
|
||||||
|
*/
|
||||||
|
fun syncHeights() {
|
||||||
|
Log.d(TAG, "🔄 syncHeights called: keyboardHeight=$keyboardHeight, emojiHeight=$emojiHeight")
|
||||||
|
if (keyboardHeight > 100.dp) {
|
||||||
|
val oldEmojiHeight = emojiHeight
|
||||||
|
emojiHeight = keyboardHeight
|
||||||
|
Log.d(TAG, "✅ Heights synced: $oldEmojiHeight → $keyboardHeight")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "⏭️ Keyboard height too small ($keyboardHeight), skipping sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текущую высоту для резервирования места.
|
||||||
|
* Telegram паттерн: всегда резервировать максимум из двух.
|
||||||
|
*/
|
||||||
|
fun getReservedHeight(): Dp {
|
||||||
|
return when {
|
||||||
|
isKeyboardVisible -> keyboardHeight
|
||||||
|
isEmojiVisible -> emojiHeight
|
||||||
|
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
||||||
|
else -> 0.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка, можно ли начать новый переход.
|
||||||
|
*/
|
||||||
|
fun canStartTransition(): Boolean {
|
||||||
|
return !isTransitioning
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сброс состояния (для отладки).
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
Log.d(TAG, "🔄 Reset coordinator state")
|
||||||
|
currentState = TransitionState.IDLE
|
||||||
|
isTransitioning = false
|
||||||
|
isKeyboardVisible = false
|
||||||
|
isEmojiVisible = false
|
||||||
|
transitionProgress = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование текущего состояния.
|
||||||
|
*/
|
||||||
|
fun logState() {
|
||||||
|
Log.d(TAG, """
|
||||||
|
📊 Coordinator State:
|
||||||
|
- state: $currentState
|
||||||
|
- transitioning: $isTransitioning
|
||||||
|
- keyboardVisible: $isKeyboardVisible (height=$keyboardHeight)
|
||||||
|
- emojiVisible: $isEmojiVisible (height=$emojiHeight)
|
||||||
|
- progress: $transitionProgress
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для создания и запоминания coordinator'а.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberKeyboardTransitionCoordinator(): KeyboardTransitionCoordinator {
|
||||||
|
return remember { KeyboardTransitionCoordinator() }
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ import com.rosetta.messenger.ui.components.AppleEmojiText
|
|||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
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.view.inputmethod.InputMethodManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -1989,19 +1991,32 @@ private fun MessageInputBar(
|
|||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
// 🎯 Координатор плавных переходов клавиатуры (Telegram-style)
|
||||||
|
val coordinator = rememberKeyboardTransitionCoordinator()
|
||||||
|
|
||||||
// 🔥 Ссылка на EditText для программного фокуса
|
// 🔥 Ссылка на EditText для программного фокуса
|
||||||
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
|
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
|
||||||
|
|
||||||
// 🔥 Автофокус при открытии reply панели
|
// 🔥 Автофокус при открытии reply панели
|
||||||
LaunchedEffect(hasReply, editTextView) {
|
LaunchedEffect(hasReply, editTextView) {
|
||||||
if (hasReply) {
|
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
|
// Даём время на создание view если ещё null
|
||||||
kotlinx.coroutines.delay(50)
|
kotlinx.coroutines.delay(50)
|
||||||
editTextView?.let { editText ->
|
editTextView?.let { editText ->
|
||||||
|
// 🔥 НЕ открываем клавиатуру если emoji уже открыт
|
||||||
|
if (!showEmojiPicker) {
|
||||||
|
android.util.Log.d("EmojiPicker", " ⌨️ Requesting focus and keyboard for reply...")
|
||||||
editText.requestFocus()
|
editText.requestFocus()
|
||||||
// Открываем клавиатуру
|
// Открываем клавиатуру
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
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) }
|
var isKeyboardAnimating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 🔥 Логирование изменений высоты клавиатуры
|
// 🔥 Логирование изменений высоты клавиатуры + обновление coordinator
|
||||||
LaunchedEffect(imeHeight) {
|
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)
|
// 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences)
|
||||||
@@ -2067,71 +2097,45 @@ private fun MessageInputBar(
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Функция переключения emoji picker - МАКСИМАЛЬНО АГРЕССИВНОЕ ОТКРЫТИЕ КЛАВИАТУРЫ
|
// 🔥 Функция переключения emoji picker с Telegram-style transitions
|
||||||
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", "=".repeat(60))
|
android.util.Log.d("EmojiPicker", "=".repeat(60))
|
||||||
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker START")
|
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker START")
|
||||||
android.util.Log.d("EmojiPicker", " State: showEmojiPicker=$showEmojiPicker, isKeyboardVisible=$isKeyboardVisible")
|
android.util.Log.d("EmojiPicker", " showEmojiPicker(local)=$showEmojiPicker")
|
||||||
android.util.Log.d("EmojiPicker", " IME height: $imeHeight, editTextView=${if (editTextView != null) "SET" else "NULL"}")
|
coordinator.logState()
|
||||||
|
|
||||||
if (showEmojiPicker) {
|
// 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного состояния
|
||||||
// ========== ЗАКРЫВАЕМ EMOJI → ОТКРЫВАЕМ КЛАВИАТУРУ ==========
|
if (coordinator.isEmojiVisible) {
|
||||||
android.util.Log.d("EmojiPicker", "📱 Action: CLOSING emoji → OPENING keyboard")
|
// ========== EMOJI → KEYBOARD ==========
|
||||||
val startTime = System.currentTimeMillis()
|
android.util.Log.d("EmojiPicker", "🔄 Switching: Emoji → Keyboard")
|
||||||
|
coordinator.requestShowKeyboard(
|
||||||
// Шаг 1: Закрываем emoji панель
|
showKeyboard = {
|
||||||
onToggleEmojiPicker(false)
|
|
||||||
android.util.Log.d("EmojiPicker", " [1] Emoji panel closed")
|
|
||||||
|
|
||||||
// Шаг 2: Немедленно фокусируем и открываем клавиатуру
|
|
||||||
editTextView?.let { editText ->
|
editTextView?.let { editText ->
|
||||||
editText.requestFocus()
|
editText.requestFocus()
|
||||||
|
|
||||||
// Метод 1: Немедленный вызов
|
|
||||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
|
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
|
||||||
android.util.Log.d("EmojiPicker", " [2] Method 1: showSoftInput(FORCED) called")
|
android.util.Log.d("EmojiPicker", "📱 Keyboard show requested")
|
||||||
|
|
||||||
// Метод 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)
|
hideEmoji = {
|
||||||
view.postDelayed({
|
onToggleEmojiPicker(false)
|
||||||
val elapsed = System.currentTimeMillis() - startTime
|
android.util.Log.d("EmojiPicker", "😊 Emoji panel hidden")
|
||||||
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")
|
|
||||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
|
|
||||||
} else {
|
|
||||||
android.util.Log.d("EmojiPicker", " [5] ✅ Keyboard opened successfully in ${elapsed}ms!")
|
|
||||||
}
|
}
|
||||||
}, 200)
|
)
|
||||||
} ?: android.util.Log.e("EmojiPicker", " ❌ ERROR: editTextView is null!")
|
|
||||||
} else {
|
} else {
|
||||||
// ========== ОТКРЫВАЕМ EMOJI → ЗАКРЫВАЕМ КЛАВИАТУРУ ==========
|
// ========== KEYBOARD → EMOJI ==========
|
||||||
android.util.Log.d("EmojiPicker", "😊 Action: OPENING emoji → CLOSING keyboard")
|
android.util.Log.d("EmojiPicker", "🔄 Switching: Keyboard → Emoji")
|
||||||
|
coordinator.requestShowEmoji(
|
||||||
// Шаг 1: Скрываем клавиатуру
|
hideKeyboard = {
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
android.util.Log.d("EmojiPicker", " [1] Keyboard hide requested")
|
android.util.Log.d("EmojiPicker", "⌨️ Keyboard hide requested")
|
||||||
|
},
|
||||||
// Шаг 2: Небольшая задержка для плавности
|
showEmoji = {
|
||||||
view.postDelayed({
|
|
||||||
// Шаг 3: Открываем emoji панель
|
|
||||||
onToggleEmojiPicker(true)
|
onToggleEmojiPicker(true)
|
||||||
android.util.Log.d("EmojiPicker", " [2] ✅ Emoji panel opened (50ms delay)")
|
android.util.Log.d("EmojiPicker", "😊 Emoji panel shown")
|
||||||
}, 50)
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker END")
|
android.util.Log.d("EmojiPicker", "🔥 toggleEmojiPicker END")
|
||||||
@@ -2309,26 +2313,6 @@ private fun MessageInputBar(
|
|||||||
.background(
|
.background(
|
||||||
color = backgroundColor // Тот же цвет что и фон чата
|
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),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
contentAlignment = Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался
|
contentAlignment = Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался
|
||||||
) {
|
) {
|
||||||
@@ -2344,7 +2328,29 @@ private fun MessageInputBar(
|
|||||||
onViewCreated = { view ->
|
onViewCreated = { view ->
|
||||||
// 🔥 Сохраняем ссылку на EditText для программного открытия клавиатуры
|
// 🔥 Сохраняем ссылку на EditText для программного открытия клавиатуры
|
||||||
editTextView = view
|
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)
|
} // End of else (not blocked)
|
||||||
|
|
||||||
|
|
||||||
// 🔥 ОПТИМИЗИРОВАННЫЙ EMOJI PICKER - с предзагрузкой и smooth animations
|
// 🔥 EMOJI PICKER с плавными Telegram-style анимациями
|
||||||
if (!isBlocked) {
|
if (!isBlocked) {
|
||||||
// Новый оптимизированный пикер автоматически управляет анимациями
|
AnimatedKeyboardTransition(
|
||||||
|
coordinator = coordinator,
|
||||||
|
showEmojiPicker = showEmojiPicker
|
||||||
|
) {
|
||||||
OptimizedEmojiPicker(
|
OptimizedEmojiPicker(
|
||||||
isVisible = showEmojiPicker && !isKeyboardVisible,
|
isVisible = true, // Видимость контролирует AnimatedKeyboardTransition
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onEmojiSelected = { emoji ->
|
onEmojiSelected = { emoji ->
|
||||||
onValueChange(value + emoji)
|
onValueChange(value + emoji)
|
||||||
},
|
},
|
||||||
onClose = {
|
onClose = {
|
||||||
// 🔥 Используем toggleEmojiPicker() - он закроет панель и откроет клавиатуру
|
// Используем coordinator для плавного перехода
|
||||||
toggleEmojiPicker()
|
toggleEmojiPicker()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} // End of if (!isBlocked) for emoji picker
|
} // End of if (!isBlocked) for emoji picker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ fun AppleEmojiTextField(
|
|||||||
hint: String = "Message",
|
hint: String = "Message",
|
||||||
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
|
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
|
||||||
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
||||||
requestFocus: Boolean = false
|
requestFocus: Boolean = false,
|
||||||
|
onFocusChanged: ((Boolean) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
// Храним ссылку на view для управления фокусом
|
// Храним ссылку на view для управления фокусом
|
||||||
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
||||||
@@ -236,6 +237,14 @@ fun AppleEmojiTextField(
|
|||||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
// Сохраняем ссылку на view
|
// Сохраняем ссылку на view
|
||||||
editTextView = this
|
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
|
// Уведомляем о создании view
|
||||||
onViewCreated?.invoke(this)
|
onViewCreated?.invoke(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ 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 (Telegram-style)
|
* 5. Coil оптимизация (hardware acceleration, size limits)
|
||||||
* 6. Coil оптимизация (hardware acceleration, size limits)
|
* 6. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
|
||||||
* 7. SharedPreferences для сохранения высоты клавиатуры (как в Telegram)
|
* 7. keyboardDuration для синхронизации с системной клавиатурой
|
||||||
* 8. keyboardDuration для синхронизации с системной клавиатурой
|
* 8. Анимация управляется внешним AnimatedKeyboardTransition
|
||||||
*
|
*
|
||||||
* @param isVisible Видимость панели
|
* @param isVisible Видимость панели (для внутренней логики)
|
||||||
* @param isDarkTheme Темная/светлая тема
|
* @param isDarkTheme Темная/светлая тема
|
||||||
* @param onEmojiSelected Callback при выборе эмодзи
|
* @param onEmojiSelected Callback при выборе эмодзи
|
||||||
* @param onClose Callback при закрытии (не используется, панель просто скрывается)
|
* @param onClose Callback при закрытии
|
||||||
* @param modifier Модификатор
|
* @param modifier Модификатор
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
@@ -68,59 +68,19 @@ fun OptimizedEmojiPicker(
|
|||||||
// 🔥 Используем сохранённую высоту клавиатуры (как в Telegram)
|
// 🔥 Используем сохранённую высоту клавиатуры (как в Telegram)
|
||||||
val savedKeyboardHeight = rememberSavedKeyboardHeight()
|
val savedKeyboardHeight = rememberSavedKeyboardHeight()
|
||||||
|
|
||||||
// 🔥 Telegram's keyboardDuration для синхронизации анимации
|
|
||||||
val animationDuration = KeyboardHeightProvider.getKeyboardAnimationDuration().toInt()
|
|
||||||
|
|
||||||
// 🔥 Логирование изменений видимости
|
// 🔥 Логирование изменений видимости
|
||||||
LaunchedEffect(isVisible) {
|
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
|
||||||
AnimatedVisibility(
|
// Анимация теперь управляется AnimatedKeyboardTransition
|
||||||
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
|
|
||||||
)
|
|
||||||
),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
// 🎨 Hardware layer для анимаций (GPU ускорение - как в Telegram)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.graphicsLayer {
|
|
||||||
// Используем hardware layer только во время анимации
|
|
||||||
if (transition.isRunning) {
|
|
||||||
this.alpha = 1f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
EmojiPickerContent(
|
EmojiPickerContent(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onEmojiSelected = onEmojiSelected,
|
onEmojiSelected = onEmojiSelected,
|
||||||
keyboardHeight = savedKeyboardHeight
|
keyboardHeight = savedKeyboardHeight,
|
||||||
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +90,8 @@ fun OptimizedEmojiPicker(
|
|||||||
private fun EmojiPickerContent(
|
private fun EmojiPickerContent(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onEmojiSelected: (String) -> Unit,
|
onEmojiSelected: (String) -> Unit,
|
||||||
keyboardHeight: Dp
|
keyboardHeight: Dp,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
|
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)
|
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram)
|
.height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram)
|
||||||
.background(panelBackground)
|
.background(panelBackground)
|
||||||
|
|||||||
Reference in New Issue
Block a user