feat: implement Telegram-style swipe functionality and animation enhancements

This commit is contained in:
k1ngsterr1
2026-02-07 02:38:19 +05:00
parent 76ad853f79
commit ec4259492b
5 changed files with 142 additions and 117 deletions

View File

@@ -507,65 +507,9 @@ fun ChatDetailScreen(
isDarkTheme isDarkTheme
) )
// <EFBFBD> Edge swipe to go back (iOS/Telegram style) // 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer)
var edgeSwipeOffset by remember { mutableStateOf(0f) }
val edgeSwipeThreshold = 100f // px threshold для активации
val edgeZoneWidth = 30f // px зона от левого края для начала свайпа
var isEdgeSwiping by remember { mutableStateOf(false) }
// Анимация возврата
val animatedEdgeOffset by
animateFloatAsState(
targetValue = edgeSwipeOffset,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
label = "edgeSwipe"
)
// 🚀 Весь контент без дополнительной анимации (анимация в MainActivity)
Box( Box(
modifier = modifier = Modifier.fillMaxSize()
Modifier.fillMaxSize()
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { offset ->
// Начинаем свайп только если палец у левого
// края
isEdgeSwiping = offset.x < edgeZoneWidth
},
onDragEnd = {
if (isEdgeSwiping &&
edgeSwipeOffset >
edgeSwipeThreshold
) {
// Свайп достаточный - переходим
// назад
hideKeyboardAndBack()
}
edgeSwipeOffset = 0f
isEdgeSwiping = false
},
onDragCancel = {
edgeSwipeOffset = 0f
isEdgeSwiping = false
},
onHorizontalDrag = { _, dragAmount ->
if (isEdgeSwiping) {
// Только вправо (положительный
// dragAmount)
val newOffset =
edgeSwipeOffset + dragAmount
edgeSwipeOffset =
newOffset.coerceIn(0f, 300f)
}
}
)
}
.graphicsLayer {
// Сдвигаем контент при свайпе
translationX = animatedEdgeOffset
// Легкое затемнение при свайпе
alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f)
}
) { ) {
// Telegram-style solid header background (без blur) // Telegram-style solid header background (без blur)
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
@@ -2176,9 +2120,9 @@ fun ChatDetailScreen(
InAppCameraScreen( InAppCameraScreen(
onDismiss = { showInAppCamera = false }, onDismiss = { showInAppCamera = false },
onPhotoTaken = { photoUri -> onPhotoTaken = { photoUri ->
// Сразу открываем редактор! // Сначала редактор (skipEnterAnimation=1f), потом убираем камеру
showInAppCamera = false
pendingCameraPhotoUri = photoUri pendingCameraPhotoUri = photoUri
showInAppCamera = false
} }
) )
} }
@@ -2200,7 +2144,8 @@ fun ChatDetailScreen(
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showCaptionInput = true, showCaptionInput = true,
recipientName = user.title recipientName = user.title,
skipEnterAnimation = true // Из камеры — мгновенно, без fade
) )
} }

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -976,6 +977,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
// консистентности // консистентности
val currentDialogs = chatsState.dialogs val currentDialogs = chatsState.dialogs
// Telegram-style: only one item can be swiped open at a time
var swipedItemKey by remember { mutableStateOf<String?>(null) }
// Close swiped item when drawer opens
LaunchedEffect(drawerState.isOpen) {
if (drawerState.isOpen) {
swipedItemKey = null
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -1046,7 +1057,17 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
isSavedMessages, isSavedMessages,
avatarRepository = avatarRepository =
avatarRepository, avatarRepository,
isDrawerOpen = drawerState.isOpen || drawerState.isAnimationRunning,
isSwipedOpen =
swipedItemKey == dialog.opponentKey,
onSwipeStarted = {
swipedItemKey = dialog.opponentKey
},
onSwipeClosed = {
if (swipedItemKey == dialog.opponentKey) swipedItemKey = null
},
onClick = { onClick = {
swipedItemKey = null
val user = val user =
chatsViewModel chatsViewModel
.dialogToSearchUser( .dialogToSearchUser(
@@ -1502,6 +1523,10 @@ fun SwipeableDialogItem(
isBlocked: Boolean = false, isBlocked: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
isDrawerOpen: Boolean = false,
isSwipedOpen: Boolean = false,
onSwipeStarted: () -> Unit = {},
onSwipeClosed: () -> Unit = {},
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onBlock: () -> Unit = {}, onBlock: () -> Unit = {},
@@ -1516,11 +1541,19 @@ fun SwipeableDialogItem(
// Фиксированная высота элемента (как в DialogItem) // Фиксированная высота элемента (как в DialogItem)
val itemHeight = 80.dp val itemHeight = 80.dp
// Анимация возврата // Close when another item starts swiping (like Telegram)
LaunchedEffect(isSwipedOpen) {
if (!isSwipedOpen && offsetX != 0f) {
offsetX = 0f
}
}
// Telegram-style easing
val telegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
val animatedOffsetX by val animatedOffsetX by
animateFloatAsState( animateFloatAsState(
targetValue = offsetX, targetValue = offsetX,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), animationSpec = tween(durationMillis = 200, easing = telegramEasing),
label = "swipeOffset" label = "swipeOffset"
) )
@@ -1546,6 +1579,7 @@ fun SwipeableDialogItem(
if (isBlocked) onUnblock() if (isBlocked) onUnblock()
else onBlock() else onBlock()
offsetX = 0f offsetX = 0f
onSwipeClosed()
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -1586,6 +1620,7 @@ fun SwipeableDialogItem(
.clickable { .clickable {
// Закрываем свайп мгновенно перед удалением // Закрываем свайп мгновенно перед удалением
offsetX = 0f offsetX = 0f
onSwipeClosed()
onDelete() onDelete()
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -1618,35 +1653,52 @@ fun SwipeableDialogItem(
.offset { IntOffset(animatedOffsetX.toInt(), 0) } .offset { IntOffset(animatedOffsetX.toInt(), 0) }
.background(backgroundColor) .background(backgroundColor)
.pointerInput(Unit) { .pointerInput(Unit) {
val edgeZone = 50.dp.toPx() // Зона для drawer val velocityTracker = VelocityTracker()
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
// Don't handle swipes when drawer is open
// 🔥 Если касание в левой зоне - НЕ захватываем pointer if (isDrawerOpen) return@awaitEachGesture
// Пусть ModalNavigationDrawer обработает
if (startX < edgeZone) { velocityTracker.resetTracking()
return@awaitEachGesture var started = false
}
// Обрабатываем swipe-to-delete только для остальной части
try { try {
horizontalDrag(down.id) { change -> horizontalDrag(down.id) { change ->
val dragAmount = change.positionChange().x val dragAmount = change.positionChange().x
// Только свайп влево
// First movement determines direction
if (!started) {
// Swipe right with actions closed — let drawer handle it
if (dragAmount > 0 && offsetX == 0f) {
return@horizontalDrag
}
started = true
onSwipeStarted()
}
val newOffset = offsetX + dragAmount val newOffset = offsetX + dragAmount
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume() change.consume()
} }
} catch (e: Exception) { } catch (_: Exception) {
offsetX = 0f offsetX = 0f
} }
// onDragEnd if (started) {
if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) { val velocity = velocityTracker.calculateVelocity().x
offsetX = -swipeWidthPx // Telegram-like: fling left (-velocity) OR dragged past 1/3
} else { val shouldOpen = velocity < -300f ||
offsetX = 0f kotlin.math.abs(offsetX) > swipeWidthPx / 3
if (shouldOpen) {
offsetX = -swipeWidthPx
} else {
offsetX = 0f
onSwipeClosed()
}
} }
} }
} }

View File

@@ -136,7 +136,8 @@ fun ImageEditorScreen(
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
showCaptionInput: Boolean = false, showCaptionInput: Boolean = false,
recipientName: String? = null, recipientName: String? = null,
thumbnailPosition: ThumbnailPosition? = null // Позиция для Telegram-style анимации thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации
skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -151,20 +152,22 @@ fun ImageEditorScreen(
// 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION // 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
var isClosing by remember { mutableStateOf(false) } var isClosing by remember { mutableStateOf(false) }
val animationProgress = remember { Animatable(0f) } val animationProgress = remember { Animatable(if (skipEnterAnimation) 1f else 0f) }
// 🎬 Плавный easing для fade-out (как в Telegram) // 🎬 Плавный easing для fade-out (как в Telegram)
val smoothEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) val smoothEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
// Запуск enter анимации // Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
animationProgress.animateTo( if (!skipEnterAnimation) {
targetValue = 1f, animationProgress.animateTo(
animationSpec = tween( targetValue = 1f,
durationMillis = 250, animationSpec = tween(
easing = FastOutSlowInEasing durationMillis = 250,
easing = FastOutSlowInEasing
)
) )
) }
} }
// 🎨 Плавная анимация status bar синхронно с fade // 🎨 Плавная анимация status bar синхронно с fade

View File

@@ -370,13 +370,19 @@ fun ImageViewerScreen(
), ),
pageSpacing = 30.dp, // Telegram: dp(30) между фото pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId }, key = { images[it].attachmentId },
userScrollEnabled = !isAnimating // Отключаем скролл во время анимации userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
beyondBoundsPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapPositionalThreshold = 0.5f
)
) { page -> ) { page ->
val image = images[page] val image = images[page]
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется // 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
val isRightPage = pageOffset < 0 // Эффект только для соседней правой страницы, не для текущей на границе
val isRightPage = pageOffset < 0 && page != pagerState.currentPage
// Scale эффект только для правой страницы (1.0 → 0.7) // Scale эффект только для правой страницы (1.0 → 0.7)
val scaleFactor = if (isRightPage) { val scaleFactor = if (isRightPage) {

View File

@@ -23,11 +23,11 @@ import kotlinx.coroutines.launch
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
// Swipe-back thresholds // Swipe-back thresholds (Telegram-like)
private const val COMPLETION_THRESHOLD = 0.3f // 30% of screen width (was 50%) private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
private const val FLING_VELOCITY_THRESHOLD = 400f // px/s (was 600) private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
private const val ANIMATION_DURATION_ENTER = 300 private const val ANIMATION_DURATION_ENTER = 300
private const val ANIMATION_DURATION_EXIT = 250 private const val ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 200 private const val EDGE_ZONE_DP = 200
/** /**
@@ -148,6 +148,7 @@ fun SwipeBackContainer(
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
@@ -159,35 +160,54 @@ fun SwipeBackContainer(
velocityTracker.resetTracking() velocityTracker.resetTracking()
var startedSwipe = false var startedSwipe = false
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
try { // Use Initial pass to intercept BEFORE children
horizontalDrag(down.id) { change -> while (true) {
val dragAmount = change.positionChange().x val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id }
?: break
// Only start swipe if moving right if (change.changedToUpIgnoreConsumed()) {
if (!startedSwipe && dragAmount > 0) { break
}
val dragDelta = change.positionChange()
totalDragX += dragDelta.x
totalDragY += dragDelta.y
if (!passedSlop) {
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (totalDistance < touchSlop) continue
// Slop exceeded — only claim rightward + mostly horizontal
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
passedSlop = true
startedSwipe = true startedSwipe = true
isDragging = true isDragging = true
dragOffset = offsetAnimatable.value dragOffset = offsetAnimatable.value
// 🔥 Скрываем клавиатуру при начале свайпа (надёжный метод)
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus() focusManager.clearFocus()
}
if (startedSwipe) {
// Direct state update - NO coroutines!
dragOffset = (dragOffset + dragAmount)
.coerceIn(0f, screenWidthPx)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume() change.consume()
} else {
// Vertical or leftward — let children handle
break
} }
} else {
// We own the gesture — update drag
dragOffset = (dragOffset + dragDelta.x)
.coerceIn(0f, screenWidthPx)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume()
} }
} catch (_: Exception) {
// Gesture was cancelled
} }
// Handle drag end // Handle drag end
@@ -196,13 +216,12 @@ fun SwipeBackContainer(
val velocity = velocityTracker.calculateVelocity().x val velocity = velocityTracker.calculateVelocity().x
val currentProgress = dragOffset / screenWidthPx val currentProgress = dragOffset / screenWidthPx
// Telegram logic: fling OR 50% threshold
val shouldComplete = val shouldComplete =
velocity > FLING_VELOCITY_THRESHOLD || currentProgress > 0.5f || // Past 50% — always complete
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
(currentProgress > COMPLETION_THRESHOLD && (currentProgress > COMPLETION_THRESHOLD &&
velocity > -FLING_VELOCITY_THRESHOLD) velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
// Sync animatable with current drag position and animate
scope.launch { scope.launch {
offsetAnimatable.snapTo(dragOffset) offsetAnimatable.snapTo(dragOffset)