feat: implement swipe back navigation and integrate VerifiedBadge in chat dialogs
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||
|
||||
// Constants matching Telegram
|
||||
private const val COMPLETION_THRESHOLD = 0.5f // 50% of screen width
|
||||
private const val FLING_VELOCITY_THRESHOLD = 600f // px/s
|
||||
private const val ANIMATION_DURATION_ENTER = 300
|
||||
private const val ANIMATION_DURATION_EXIT = 250
|
||||
private const val EDGE_ZONE_DP = 200
|
||||
|
||||
/**
|
||||
* Telegram-style swipe back container (optimized)
|
||||
*
|
||||
* Wraps content and allows swiping from the left edge to go back.
|
||||
* Features:
|
||||
* - Edge-only swipe detection (left 30dp)
|
||||
* - Direct state update during drag (no coroutine overhead)
|
||||
* - VelocityTracker for fling detection
|
||||
* - Smooth Telegram-style bezier animation
|
||||
* - Scrim (dimming) on background
|
||||
* - Shadow on left edge during swipe
|
||||
* - Threshold: 50% of width OR 600px/s fling velocity
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeBackContainer(
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
swipeEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
|
||||
|
||||
// Animation state for swipe (used only for swipe animations, not during drag)
|
||||
val offsetAnimatable = remember { Animatable(0f) }
|
||||
|
||||
// Alpha animation for fade-in entry
|
||||
val alphaAnimatable = remember { Animatable(0f) }
|
||||
|
||||
// Drag state - direct update without animation
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
// Visibility state
|
||||
var shouldShow by remember { mutableStateOf(false) }
|
||||
var isAnimatingIn by remember { mutableStateOf(false) }
|
||||
var isAnimatingOut by remember { mutableStateOf(false) }
|
||||
|
||||
// Coroutine scope for animations
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Current offset: use drag offset during drag, animatable otherwise
|
||||
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
|
||||
// Current alpha: use animatable during fade animations, otherwise 1
|
||||
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
||||
|
||||
// Scrim alpha based on swipe progress
|
||||
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
|
||||
|
||||
// Handle visibility changes
|
||||
LaunchedEffect(isVisible) {
|
||||
if (isVisible && !shouldShow) {
|
||||
// Animate in: fade-in
|
||||
shouldShow = true
|
||||
isAnimatingIn = true
|
||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||
alphaAnimatable.snapTo(0f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_ENTER,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
isAnimatingIn = false
|
||||
} else if (!isVisible && shouldShow && !isAnimatingOut) {
|
||||
// Animate out: fade-out (when triggered by button, not swipe)
|
||||
isAnimatingOut = true
|
||||
alphaAnimatable.snapTo(1f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
shouldShow = false
|
||||
isAnimatingOut = false
|
||||
offsetAnimatable.snapTo(0f)
|
||||
alphaAnimatable.snapTo(0f)
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Scrim (dimming layer behind the screen) - only when swiping
|
||||
if (currentOffset > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||
)
|
||||
}
|
||||
|
||||
// Content with swipe gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = currentOffset
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
// Edge-only detection
|
||||
if (down.position.x > edgeZonePx) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
velocityTracker.resetTracking()
|
||||
var startedSwipe = false
|
||||
|
||||
try {
|
||||
horizontalDrag(down.id) { change ->
|
||||
val dragAmount = change.positionChange().x
|
||||
|
||||
// Only start swipe if moving right
|
||||
if (!startedSwipe && dragAmount > 0) {
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
}
|
||||
|
||||
if (startedSwipe) {
|
||||
// Direct state update - NO coroutines!
|
||||
dragOffset = (dragOffset + dragAmount)
|
||||
.coerceIn(0f, screenWidthPx)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Gesture was cancelled
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val currentProgress = dragOffset / screenWidthPx
|
||||
|
||||
// Telegram logic: fling OR 50% threshold
|
||||
val shouldComplete =
|
||||
velocity > FLING_VELOCITY_THRESHOLD ||
|
||||
(currentProgress > COMPLETION_THRESHOLD &&
|
||||
velocity > -FLING_VELOCITY_THRESHOLD)
|
||||
|
||||
// Sync animatable with current drag position and animate
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(dragOffset)
|
||||
|
||||
if (shouldComplete) {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user