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
private fun SlideToCancel(
slideDx: Float,
@@ -576,46 +589,119 @@ private fun SlideToCancel(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f)
val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f
// slideProgress: 1.0 = at rest, decreases as finger drags left toward cancel
val slideProgress = 1f - ((-slideDx) / cancelThresholdPx).coerceIn(0f, 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 = arrowAmplitude,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "slide_cancel_arrow_offset"
val density = LocalDensity.current
// Pre-compute px values for use in LaunchedEffect
val maxOffsetPx = with(density) { 6.dp.toPx() }
val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } // 12dp/s
// Telegram: arrow oscillates ±6dp only when slideProgress > 0.8
var xOffset by remember { mutableFloatStateOf(0f) }
var moveForward by remember { mutableStateOf(true) }
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(
modifier = modifier
.graphicsLayer {
translationX = slideDx * 0.3f
alpha = textAlpha
// Telegram: text follows finger × damping + entry slide + pulse offset
translationX = slideDx * 0.3f + entryTranslation +
xOffset * slideProgress
alpha = slideProgress * entryAlpha
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "",
color = textColor,
fontSize = 13.sp,
modifier = Modifier.graphicsLayer { translationX = arrowOffset }
)
// Chevron arrow — Canvas-drawn, NOT text character
// Telegram: 4dp × 5dp chevron, stroke 1.6dp, round caps
Canvas(
modifier = Modifier
.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))
// "Slide to cancel" text — Telegram: 15sp, normal weight
Text(
text = "Slide to Cancel",
text = "Slide to cancel",
color = textColor,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
maxLines = 1
)
}