fix: переписать SlideToCancel 1:1 с Telegram — chevron arrow, пульсация, entry animation

Telegram-exact SlideTextView:
- Chevron arrow: Canvas-drawn path 4×5dp, stroke 1.6dp, round caps (не текст ◀)
- Пульсация: ±6dp ТОЛЬКО при slideProgress > 0.8, скорость 12dp/s (3dp/250ms)
- Frame-based animation через LaunchedEffect (не infiniteTransition)
- Entry: slide in from right (translationX 20dp→0, 200ms) + fade in
- Текст: "Slide to cancel" 15sp normal weight (было 13sp medium)
- Цвет: #8E8E93 (Telegram key_chat_recordTime)
- Translation: finger × 0.3 damping + pulse offset × slideProgress
- Alpha: slideProgress × entryAlpha (плавно появляется и исчезает при свайпе)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:57:10 +05:00
parent 8dac52c2eb
commit aa3cc76646

View File

@@ -569,6 +569,19 @@ private fun LockIcon(
} }
} }
/**
* Telegram-exact SlideToCancel.
*
* Layout: [chevron arrow] "Slide to cancel"
*
* - Arrow is a Canvas-drawn chevron (4×5dp, stroke 1.6dp, round caps)
* - Arrow oscillates ±6dp ONLY when slideProgress > 0.8 at 12dp/s
* - Text alpha = slideProgress (fades in with drag, 0 = invisible at rest)
* - Translation follows finger × 0.3 damping
* - Entry: slides in from right (translationX 20dp→0) with fade
*
* Reference: ChatActivityEnterView.SlideTextView (lines 13083-13357)
*/
@Composable @Composable
private fun SlideToCancel( private fun SlideToCancel(
slideDx: Float, slideDx: Float,
@@ -576,46 +589,119 @@ private fun SlideToCancel(
isDarkTheme: Boolean, isDarkTheme: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) // slideProgress: 1.0 = at rest, decreases as finger drags left toward cancel
val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f val slideProgress = 1f - ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f)
// Fix #5: Arrow pulsation ±6dp when slideRatio > 0.8, else ±3dp val density = LocalDensity.current
val arrowAmplitude = if (slideRatio > 0.8f) -6f else -3f
val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") // Pre-compute px values for use in LaunchedEffect
val arrowOffset by arrowPulse.animateFloat( val maxOffsetPx = with(density) { 6.dp.toPx() }
initialValue = 0f, val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } // 12dp/s
targetValue = arrowAmplitude,
animationSpec = infiniteRepeatable( // Telegram: arrow oscillates ±6dp only when slideProgress > 0.8
animation = tween(durationMillis = 500, easing = LinearEasing), var xOffset by remember { mutableFloatStateOf(0f) }
repeatMode = RepeatMode.Reverse var moveForward by remember { mutableStateOf(true) }
),
label = "slide_cancel_arrow_offset" LaunchedEffect(slideProgress > 0.8f) {
if (slideProgress <= 0.8f) {
xOffset = 0f
moveForward = true
return@LaunchedEffect
}
var lastTime = System.nanoTime()
while (true) {
delay(16) // ~60fps
val now = System.nanoTime()
val dtMs = (now - lastTime) / 1_000_000f
lastTime = now
val step = speedPxPerMs * dtMs
if (moveForward) {
xOffset += step
if (xOffset > maxOffsetPx) {
xOffset = maxOffsetPx
moveForward = false
}
} else {
xOffset -= step
if (xOffset < -maxOffsetPx) {
xOffset = -maxOffsetPx
moveForward = true
}
}
}
}
// Colors — Telegram: key_chat_recordTime (gray), key_glass_defaultIcon (arrow)
val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val arrowColor = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF8E8E93)
// Entry animation: slide in from right
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
entered = true
}
val entryTranslation by animateFloatAsState(
targetValue = if (entered) 0f else with(density) { 20.dp.toPx() },
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "slide_cancel_entry"
)
val entryAlpha by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(durationMillis = 200),
label = "slide_cancel_entry_alpha"
) )
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( Row(
modifier = modifier modifier = modifier
.graphicsLayer { .graphicsLayer {
translationX = slideDx * 0.3f // Telegram: text follows finger × damping + entry slide + pulse offset
alpha = textAlpha translationX = slideDx * 0.3f + entryTranslation +
xOffset * slideProgress
alpha = slideProgress * entryAlpha
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( // Chevron arrow — Canvas-drawn, NOT text character
text = "", // Telegram: 4dp × 5dp chevron, stroke 1.6dp, round caps
color = textColor, Canvas(
fontSize = 13.sp, modifier = Modifier
modifier = Modifier.graphicsLayer { translationX = arrowOffset } .size(width = 10.dp, height = 14.dp)
) .graphicsLayer {
translationX = xOffset * slideProgress
}
) {
val midY = size.height / 2f
val arrowW = 4.dp.toPx()
val arrowH = 5.dp.toPx()
val strokeW = 1.6f.dp.toPx()
val startX = (size.width - arrowW) / 2f
drawLine(
color = arrowColor,
start = Offset(startX + arrowW, midY - arrowH),
end = Offset(startX, midY),
strokeWidth = strokeW,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = arrowColor,
start = Offset(startX, midY),
end = Offset(startX + arrowW, midY + arrowH),
strokeWidth = strokeW,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
}
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
// "Slide to cancel" text — Telegram: 15sp, normal weight
Text( Text(
text = "Slide to Cancel", text = "Slide to cancel",
color = textColor, color = textColor,
fontSize = 13.sp, fontSize = 15.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Normal,
maxLines = 1 maxLines = 1
) )
} }