polish: анимации записи 1:1 с Telegram — lock growth, staggered snap, EaseOutQuint, exit animation

- LockIcon: размер 50dp→36dp при свайпе, Y-позиция анимируется
- Staggered snap: rotation(250ms EASE_OUT_QUINT) + translate(350ms, delay 100ms)
- Двухфазный snap rotation с snapRotateBackProgress (порог 40%)
- SlideToCancel: пульсация ±6dp при >80%, демпфирование 0.3
- Send кнопка: scale-анимация 0→1 (150ms)
- Exit: AnimatedVisibility с fadeOut+shrinkVertically (300ms)
- Cancel distance: 92dp→140dp
- Фикс прыжка инпута при смене Voice/Video

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 21:26:40 +05:00
parent 3e3f501b9b
commit b6055c98a5

View File

@@ -88,6 +88,8 @@ import java.util.UUID
import kotlin.math.PI
import kotlin.math.sin
private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f)
private fun truncateEmojiSafe(text: String, maxLen: Int): String {
if (text.length <= maxLen) return text
var cutAt = maxLen
@@ -404,14 +406,21 @@ private fun LockIcon(
modifier: Modifier = Modifier
) {
val progress = lockProgress.coerceIn(0f, 1f)
val lockedOrPaused = isLocked || isPaused
val snapProgress by animateFloatAsState(
targetValue = if (isLocked || isPaused) 1f else 0f,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
label = "lock_snap"
// Staggered snap animations (Fix #3)
val snapRotation by animateFloatAsState(
targetValue = if (lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 250, easing = EaseOutQuint),
label = "lock_snap_rotation"
)
val snapTranslate by animateFloatAsState(
targetValue = if (lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 350, delayMillis = 100, easing = LinearOutSlowInEasing),
label = "lock_snap_translate"
)
val pauseTransform by animateFloatAsState(
targetValue = if (isPaused || isLocked) 1f else 0f,
targetValue = if (lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "lock_to_pause"
)
@@ -421,7 +430,7 @@ private fun LockIcon(
val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f)
val enterAlpha by animateFloatAsState(
targetValue = if (lockProgress > 0.01f || isLocked || isPaused) 1f else 0f,
targetValue = if (lockProgress > 0.01f || lockedOrPaused) 1f else 0f,
animationSpec = tween(durationMillis = 150),
label = "lock_enter_alpha"
)
@@ -432,9 +441,13 @@ private fun LockIcon(
) {
val cx = size.width / 2f
val lockSize = size.minDimension
val moveProgress = if (isLocked || isPaused) 1f else progress
val currentRotation = if (isLocked || isPaused) {
-15f * snapProgress * (1f - snapProgress)
val moveProgress = if (lockedOrPaused) 1f else progress
// Dual-phase snap rotation (Fix #4) — Telegram formula
val snapRotateBackProgress = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f
val currentRotation = if (lockedOrPaused) {
9f * (1f - moveProgress) * (1f - snapRotation) -
15f * snapRotation * (1f - snapRotateBackProgress)
} else {
9f * (1f - moveProgress)
}
@@ -520,12 +533,14 @@ private fun SlideToCancel(
val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f)
val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f
// Fix #5: Arrow pulsation ±6dp when slideRatio > 0.8, else ±3dp
val arrowAmplitude = if (slideRatio > 0.8f) -6f else -3f
val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow")
val arrowOffset by arrowPulse.animateFloat(
initialValue = 0f,
targetValue = -3f,
targetValue = arrowAmplitude,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 750, easing = LinearEasing),
animation = tween(durationMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "slide_cancel_arrow_offset"
@@ -533,10 +548,11 @@ private fun SlideToCancel(
val textColor = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f)
// Fix #6: damping 0.3 (Telegram-style)
Row(
modifier = modifier
.graphicsLayer {
translationX = slideDx * 0.5f
translationX = slideDx * 0.3f
alpha = textAlpha
},
verticalAlignment = Alignment.CenterVertically,
@@ -961,7 +977,6 @@ fun MessageInputBar(
isKeyboardVisible ||
coordinator.isEmojiBoxVisible ||
isVoiceRecordTransitioning ||
recordUiState == RecordUiState.PRESSING ||
recordUiState == RecordUiState.PAUSED
val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput
@@ -1139,7 +1154,6 @@ fun MessageInputBar(
isKeyboardVisible ||
coordinator.isEmojiBoxVisible ||
isVoiceRecordTransitioning ||
recordUiState == RecordUiState.PRESSING ||
recordUiState == RecordUiState.PAUSED
val navPad = hasNativeNavigationBar && !pinBottom
"mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " +
@@ -1196,7 +1210,7 @@ fun MessageInputBar(
}
val holdToRecordDelayMs = 260L
val cancelDragThresholdPx = with(density) { 92.dp.toPx() }
val cancelDragThresholdPx = with(density) { 140.dp.toPx() }
val lockDragThresholdPx = with(density) { 70.dp.toPx() }
var showLockTooltip by remember { mutableStateOf(false) }
@@ -1907,7 +1921,12 @@ fun MessageInputBar(
}
}
if (isVoiceRecording) {
// Fix #8: AnimatedVisibility for smooth exit
androidx.compose.animation.AnimatedVisibility(
visible = isVoiceRecording,
enter = fadeIn(tween(180)) + expandVertically(tween(180)),
exit = fadeOut(tween(300)) + shrinkVertically(tween(300))
) {
val recordingPanelColor =
if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
val recordingTextColor =
@@ -2055,14 +2074,18 @@ fun MessageInputBar(
recordUiState == RecordUiState.LOCKED ||
recordUiState == RecordUiState.PAUSED
) {
// Fix #1: Lock grows 50dp→36dp as lockProgress 0→1
// Fix #2: Y-position animates closer to mic
val lockSizeDp = (50.dp - 14.dp * lockProgress)
val lockYOffset = ((-60).dp + 14.dp * lockProgress)
LockIcon(
lockProgress = lockProgress,
isLocked = recordUiState == RecordUiState.LOCKED,
isPaused = recordUiState == RecordUiState.PAUSED,
isDarkTheme = isDarkTheme,
modifier = Modifier
.size(36.dp)
.offset(y = (-60).dp)
.size(lockSizeDp)
.offset(y = lockYOffset)
.zIndex(10f)
)
@@ -2090,10 +2113,20 @@ fun MessageInputBar(
}
)
// Fix #7: Send button with scale animation
val sendScale by animateFloatAsState(
targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f,
animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing),
label = "send_btn_scale"
)
if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) {
Box(
modifier = Modifier
.requiredSize(82.dp)
.graphicsLayer {
scaleX = sendScale
scaleY = sendScale
}
.shadow(
elevation = 10.dp,
shape = CircleShape,
@@ -2138,7 +2171,8 @@ fun MessageInputBar(
}
}
}
} else {
}
if (!isVoiceRecording) {
Row(
modifier = Modifier
.fillMaxWidth()