From ec4259492b4a7ae2e5dd5527abc929a1b4e2a604 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Feb 2026 02:38:19 +0500 Subject: [PATCH] feat: implement Telegram-style swipe functionality and animation enhancements --- .../messenger/ui/chats/ChatDetailScreen.kt | 67 ++------------ .../messenger/ui/chats/ChatsListScreen.kt | 92 +++++++++++++++---- .../ui/chats/components/ImageEditorScreen.kt | 21 +++-- .../ui/chats/components/ImageViewerScreen.kt | 10 +- .../ui/components/SwipeBackContainer.kt | 69 +++++++++----- 5 files changed, 142 insertions(+), 117 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index b3b9405..8269393 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -507,65 +507,9 @@ fun ChatDetailScreen( isDarkTheme ) - // � Edge swipe to go back (iOS/Telegram style) - 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) + // 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer) Box( - modifier = - 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) - } + modifier = Modifier.fillMaxSize() ) { // Telegram-style solid header background (без blur) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) @@ -2176,9 +2120,9 @@ fun ChatDetailScreen( InAppCameraScreen( onDismiss = { showInAppCamera = false }, onPhotoTaken = { photoUri -> - // Сразу открываем редактор! - showInAppCamera = false + // Сначала редактор (skipEnterAnimation=1f), потом убираем камеру pendingCameraPhotoUri = photoUri + showInAppCamera = false } ) } @@ -2200,7 +2144,8 @@ fun ChatDetailScreen( }, isDarkTheme = isDarkTheme, showCaptionInput = true, - recipientName = user.title + recipientName = user.title, + skipEnterAnimation = true // Из камеры — мгновенно, без fade ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 612be13..c168a34 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput 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.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -976,6 +977,16 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren // консистентности val currentDialogs = chatsState.dialogs + // Telegram-style: only one item can be swiped open at a time + var swipedItemKey by remember { mutableStateOf(null) } + + // Close swiped item when drawer opens + LaunchedEffect(drawerState.isOpen) { + if (drawerState.isOpen) { + swipedItemKey = null + } + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -1046,7 +1057,17 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren isSavedMessages, avatarRepository = avatarRepository, + isDrawerOpen = drawerState.isOpen || drawerState.isAnimationRunning, + isSwipedOpen = + swipedItemKey == dialog.opponentKey, + onSwipeStarted = { + swipedItemKey = dialog.opponentKey + }, + onSwipeClosed = { + if (swipedItemKey == dialog.opponentKey) swipedItemKey = null + }, onClick = { + swipedItemKey = null val user = chatsViewModel .dialogToSearchUser( @@ -1502,6 +1523,10 @@ fun SwipeableDialogItem( isBlocked: Boolean = false, isSavedMessages: Boolean = false, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, + isDrawerOpen: Boolean = false, + isSwipedOpen: Boolean = false, + onSwipeStarted: () -> Unit = {}, + onSwipeClosed: () -> Unit = {}, onClick: () -> Unit, onDelete: () -> Unit = {}, onBlock: () -> Unit = {}, @@ -1516,11 +1541,19 @@ fun SwipeableDialogItem( // Фиксированная высота элемента (как в DialogItem) 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 animateFloatAsState( targetValue = offsetX, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), + animationSpec = tween(durationMillis = 200, easing = telegramEasing), label = "swipeOffset" ) @@ -1546,6 +1579,7 @@ fun SwipeableDialogItem( if (isBlocked) onUnblock() else onBlock() offsetX = 0f + onSwipeClosed() }, contentAlignment = Alignment.Center ) { @@ -1586,6 +1620,7 @@ fun SwipeableDialogItem( .clickable { // Закрываем свайп мгновенно перед удалением offsetX = 0f + onSwipeClosed() onDelete() }, contentAlignment = Alignment.Center @@ -1618,35 +1653,52 @@ fun SwipeableDialogItem( .offset { IntOffset(animatedOffsetX.toInt(), 0) } .background(backgroundColor) .pointerInput(Unit) { - val edgeZone = 50.dp.toPx() // Зона для drawer + val velocityTracker = VelocityTracker() awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - val startX = down.position.x - - // 🔥 Если касание в левой зоне - НЕ захватываем pointer - // Пусть ModalNavigationDrawer обработает - if (startX < edgeZone) { - return@awaitEachGesture - } - - // Обрабатываем swipe-to-delete только для остальной части + + // Don't handle swipes when drawer is open + if (isDrawerOpen) return@awaitEachGesture + + velocityTracker.resetTracking() + var started = false try { horizontalDrag(down.id) { change -> 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 offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) + velocityTracker.addPosition( + change.uptimeMillis, + change.position + ) change.consume() } - } catch (e: Exception) { + } catch (_: Exception) { offsetX = 0f } - - // onDragEnd - if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) { - offsetX = -swipeWidthPx - } else { - offsetX = 0f + + if (started) { + val velocity = velocityTracker.calculateVelocity().x + // Telegram-like: fling left (-velocity) OR dragged past 1/3 + val shouldOpen = velocity < -300f || + kotlin.math.abs(offsetX) > swipeWidthPx / 3 + if (shouldOpen) { + offsetX = -swipeWidthPx + } else { + offsetX = 0f + onSwipeClosed() + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 8bbc17f..2cc6a3d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -136,7 +136,8 @@ fun ImageEditorScreen( isDarkTheme: Boolean = true, showCaptionInput: Boolean = false, recipientName: String? = null, - thumbnailPosition: ThumbnailPosition? = null // Позиция для Telegram-style анимации + thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации + skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -151,20 +152,22 @@ fun ImageEditorScreen( // 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION // ═══════════════════════════════════════════════════════════════ var isClosing by remember { mutableStateOf(false) } - val animationProgress = remember { Animatable(0f) } + val animationProgress = remember { Animatable(if (skipEnterAnimation) 1f else 0f) } // 🎬 Плавный easing для fade-out (как в Telegram) val smoothEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) - // Запуск enter анимации + // Запуск enter анимации (пропускаем если из камеры — уже alpha=1) LaunchedEffect(Unit) { - animationProgress.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = 250, - easing = FastOutSlowInEasing + if (!skipEnterAnimation) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ) ) - ) + } } // 🎨 Плавная анимация status bar синхронно с fade diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index b8c5e82..e5f2c6c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -370,13 +370,19 @@ fun ImageViewerScreen( ), pageSpacing = 30.dp, // Telegram: dp(30) между фото key = { images[it].attachmentId }, - userScrollEnabled = !isAnimating // Отключаем скролл во время анимации + userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации + beyondBoundsPageCount = 0, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapPositionalThreshold = 0.5f + ) ) { page -> val image = images[page] // 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction - val isRightPage = pageOffset < 0 + // Эффект только для соседней правой страницы, не для текущей на границе + val isRightPage = pageOffset < 0 && page != pagerState.currentPage // Scale эффект только для правой страницы (1.0 → 0.7) val scaleFactor = if (isRightPage) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index b6bd483..b1b2a51 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -23,11 +23,11 @@ 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) -// Swipe-back thresholds -private const val COMPLETION_THRESHOLD = 0.3f // 30% of screen width (was 50%) -private const val FLING_VELOCITY_THRESHOLD = 400f // px/s (was 600) +// Swipe-back thresholds (Telegram-like) +private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete +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_EXIT = 250 +private const val ANIMATION_DURATION_EXIT = 200 private const val EDGE_ZONE_DP = 200 /** @@ -148,6 +148,7 @@ fun SwipeBackContainer( if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { Modifier.pointerInput(Unit) { val velocityTracker = VelocityTracker() + val touchSlop = viewConfiguration.touchSlop awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) @@ -159,35 +160,54 @@ fun SwipeBackContainer( velocityTracker.resetTracking() var startedSwipe = false + var totalDragX = 0f + var totalDragY = 0f + var passedSlop = false - try { - horizontalDrag(down.id) { change -> - val dragAmount = change.positionChange().x + // Use Initial pass to intercept BEFORE children + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } + ?: break - // Only start swipe if moving right - if (!startedSwipe && dragAmount > 0) { + if (change.changedToUpIgnoreConsumed()) { + 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 isDragging = true dragOffset = offsetAnimatable.value - // 🔥 Скрываем клавиатуру при начале свайпа (надёжный метод) + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus() - } - if (startedSwipe) { - // Direct state update - NO coroutines! - dragOffset = (dragOffset + dragAmount) - .coerceIn(0f, screenWidthPx) - velocityTracker.addPosition( - change.uptimeMillis, - change.position - ) 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 @@ -196,13 +216,12 @@ fun SwipeBackContainer( val velocity = velocityTracker.calculateVelocity().x val currentProgress = dragOffset / screenWidthPx - // Telegram logic: fling OR 50% threshold val shouldComplete = - velocity > FLING_VELOCITY_THRESHOLD || + currentProgress > 0.5f || // Past 50% — always complete + velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right (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 { offsetAnimatable.snapTo(dragOffset)