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