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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user