feat: Enhance emoji picker transition with fade animations and optimize imePadding behavior
This commit is contained in:
@@ -1,33 +1,31 @@
|
|||||||
package app.rosette.android.ui.keyboard
|
package app.rosette.android.ui.keyboard
|
||||||
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 МЕТОД: Animated Height + Content Offset (Telegram-style)
|
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||||
*
|
*
|
||||||
* Ключевые принципы:
|
* Принцип как в Telegram:
|
||||||
* 1. ВЫСОТА контейнера анимируется от 0dp до emojiHeight
|
* 1. Box ВСЕГДА существует когда нужен (showEmojiPicker или анимация идёт)
|
||||||
* 2. Контент внутри clipToBounds - обрезается по границам
|
* 2. Box сразу занимает место = emojiHeight
|
||||||
* 3. НЕТ предварительного резервирования места когда скрыт
|
* 3. Контент появляется через fade (alpha animation)
|
||||||
* 4. graphicsLayer для GPU-ускорения
|
* 4. Клавиатура уезжает, а эмодзи уже на месте
|
||||||
*
|
*
|
||||||
* Паттерн:
|
* Ключевое: Box показан когда showEmojiPicker ИЛИ alpha > 0 (анимация ещё идёт)
|
||||||
* - Показать: height анимируется от 0dp до emojiHeight (контент появляется снизу)
|
|
||||||
* - Скрыть: height анимируется от emojiHeight до 0dp (контент уходит вниз)
|
|
||||||
* - Длительность: 250ms с FastOutSlowInEasing
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedKeyboardTransition(
|
fun AnimatedKeyboardTransition(
|
||||||
@@ -37,59 +35,65 @@ fun AnimatedKeyboardTransition(
|
|||||||
) {
|
) {
|
||||||
val tag = "AnimatedTransition"
|
val tag = "AnimatedTransition"
|
||||||
|
|
||||||
// 🎯 Целевая высота: emojiHeight = видно, 0dp = скрыто
|
// 🔥 Отслеживаем был ли emoji открыт (для определения перехода emoji→keyboard)
|
||||||
val targetHeight = if (showEmojiPicker) {
|
val wasEmojiShown = remember { mutableStateOf(false) }
|
||||||
coordinator.emojiHeight
|
val isTransitioningToKeyboard = remember { mutableStateOf(false) }
|
||||||
} else {
|
|
||||||
0.dp
|
// Когда emoji открывается - запоминаем
|
||||||
|
LaunchedEffect(showEmojiPicker) {
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
wasEmojiShown.value = true
|
||||||
|
} else if (wasEmojiShown.value) {
|
||||||
|
// Emoji закрылся после того как был открыт = переход emoji→keyboard
|
||||||
|
isTransitioningToKeyboard.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎬 Декларативная анимация высоты
|
// Сбрасываем флаг перехода когда клавиатура достигла полной высоты
|
||||||
val animatedHeight by animateDpAsState(
|
val isKeyboardFullHeight = coordinator.keyboardHeight >= coordinator.emojiHeight * 0.95f && coordinator.emojiHeight > 0.dp
|
||||||
targetValue = targetHeight,
|
LaunchedEffect(isKeyboardFullHeight) {
|
||||||
|
if (isKeyboardFullHeight && isTransitioningToKeyboard.value) {
|
||||||
|
isTransitioningToKeyboard.value = false
|
||||||
|
wasEmojiShown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 Целевая прозрачность
|
||||||
|
val targetAlpha = if (showEmojiPicker) 1f else 0f
|
||||||
|
|
||||||
|
// 🎬 Анимация прозрачности
|
||||||
|
val animatedAlpha by animateFloatAsState(
|
||||||
|
targetValue = targetAlpha,
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = KeyboardTransitionCoordinator.TRANSITION_DURATION.toInt(),
|
durationMillis = KeyboardTransitionCoordinator.TRANSITION_DURATION.toInt(),
|
||||||
easing = FastOutSlowInEasing
|
easing = FastOutSlowInEasing
|
||||||
),
|
),
|
||||||
label = "emojiPanelHeight"
|
label = "emojiPanelAlpha"
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(tag, "🎨 Emoji panel: show=$showEmojiPicker, targetH=$targetHeight, currentH=$animatedHeight, emojiH=${coordinator.emojiHeight}")
|
// 🔥 Box показан когда:
|
||||||
|
// 1. showEmojiPicker=true (emoji открыт), ИЛИ
|
||||||
|
// 2. анимация fade-out ещё идёт (alpha > 0.01), ИЛИ
|
||||||
|
// 3. переход emoji→keyboard И клавиатура ещё не достигла полной высоты
|
||||||
|
val isFadingOut = !showEmojiPicker && animatedAlpha > 0.01f
|
||||||
|
val shouldShowBox = showEmojiPicker || isFadingOut || isTransitioningToKeyboard.value
|
||||||
|
|
||||||
// 🔥 Рендерим только если есть высота (показан или анимируется)
|
// 🔥 ВАЖНО: Обновляем состояние в координаторе для отключения imePadding
|
||||||
if (animatedHeight > 0.dp) {
|
coordinator.isEmojiBoxVisible = shouldShowBox
|
||||||
|
|
||||||
|
Log.d(tag, "🎨 Emoji panel: show=$showEmojiPicker, alpha=$animatedAlpha, shouldShow=$shouldShowBox, transitioning=${isTransitioningToKeyboard.value}, kbHeight=${coordinator.keyboardHeight}")
|
||||||
|
|
||||||
|
if (shouldShowBox) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(animatedHeight)
|
.height(coordinator.emojiHeight)
|
||||||
.clipToBounds() // 🔥 Обрезаем контент по границам контейнера
|
.graphicsLayer {
|
||||||
.graphicsLayer {
|
// 🔥 GPU-ускоренная анимация прозрачности
|
||||||
// GPU-ускорение для плавности
|
alpha = animatedAlpha
|
||||||
clip = true
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Внутренний контейнер с полной высотой emoji
|
content()
|
||||||
// Он обрезается clipToBounds родителя
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(coordinator.emojiHeight)
|
|
||||||
// 🔥 Позиционируем снизу: когда animatedHeight < emojiHeight,
|
|
||||||
// верхняя часть обрезается, создавая эффект "выезда снизу"
|
|
||||||
.layout { measurable, constraints ->
|
|
||||||
val placeable = measurable.measure(constraints.copy(
|
|
||||||
minHeight = 0,
|
|
||||||
maxHeight = coordinator.emojiHeight.roundToPx()
|
|
||||||
))
|
|
||||||
layout(placeable.width, placeable.height) {
|
|
||||||
// Смещаем вниз так, чтобы нижняя часть была видна
|
|
||||||
val offsetY = placeable.height - animatedHeight.roundToPx()
|
|
||||||
placeable.place(0, -offsetY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ class KeyboardTransitionCoordinator {
|
|||||||
var isEmojiVisible by mutableStateOf(false)
|
var isEmojiVisible by mutableStateOf(false)
|
||||||
var isTransitioning by mutableStateOf(false)
|
var isTransitioning by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
// 🔥 Показывается ли сейчас Box с эмодзи (включая анимацию fade-out)
|
||||||
|
// Используется для отключения imePadding пока Box виден
|
||||||
|
var isEmojiBoxVisible by mutableStateOf(false)
|
||||||
|
|
||||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
||||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
// ============ Главный метод: Keyboard → Emoji ============
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переход от системной клавиатуры к emoji панели.
|
* Переход от системной клавиатуры к emoji панели.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -905,10 +905,18 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие AnimatedVisibility для normal header
|
} // Закрытие AnimatedVisibility для normal header
|
||||||
},
|
},
|
||||||
containerColor = backgroundColor, // Фон всего чата
|
containerColor = backgroundColor, // Фон всего чата
|
||||||
// 🔥 Bottom bar - инпут с imePadding автоматически поднимается над клавиатурой
|
// 🔥 Bottom bar - инпут с умным padding:
|
||||||
|
// - Когда showEmojiPicker=false → imePadding (поднимается над клавиатурой)
|
||||||
|
// - Когда showEmojiPicker=true → НЕТ imePadding (Box с эмодзи сам даёт высоту)
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
// 🔥 Весь bottomBar поднимается над клавиатурой
|
// 🔥 Telegram-style: когда Box с эмодзи виден, НЕ используем imePadding
|
||||||
Column(modifier = Modifier.imePadding()) {
|
// isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01)
|
||||||
|
val bottomModifier = if (coordinator.isEmojiBoxVisible) {
|
||||||
|
Modifier // Без imePadding - Box с эмодзи заменяет клавиатуру
|
||||||
|
} else {
|
||||||
|
Modifier.imePadding() // С imePadding - клавиатура поднимает инпут
|
||||||
|
}
|
||||||
|
Column(modifier = bottomModifier) {
|
||||||
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
|
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
|
||||||
// Скрываем когда в режиме выбора
|
// Скрываем когда в режиме выбора
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|||||||
Reference in New Issue
Block a user