From 3f2b52b5780d397eb4a517f6dd1e1be7a789d5ab Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 27 Feb 2026 23:56:21 +0500 Subject: [PATCH] feat: improve swipe back scale behavior across screens --- .../com/rosetta/messenger/MainActivity.kt | 124 +++++++++--------- .../ui/components/SwipeBackContainer.kt | 123 ++++++++++++++++- 2 files changed, 184 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index cd2c127..5e199cf 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -45,6 +45,7 @@ import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.RequestsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache +import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen @@ -688,65 +689,67 @@ fun MainScreen( // 🔥 Простая навигация с swipe back Box(modifier = Modifier.fillMaxSize()) { // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) - ChatsListScreen( - isDarkTheme = isDarkTheme, - accountName = accountName, - accountUsername = accountUsername, - accountVerified = accountVerified, - accountPhone = accountPhone, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - privateKeyHash = privateKeyHash, - onToggleTheme = onToggleTheme, - onProfileClick = { pushScreen(Screen.Profile) }, - onNewGroupClick = { - // TODO: Navigate to new group - }, - onContactsClick = { - // TODO: Navigate to contacts - }, - onCallsClick = { - // TODO: Navigate to calls - }, - onSavedMessagesClick = { - // Открываем чат с самим собой (Saved Messages) - pushScreen( - Screen.ChatDetail( - SearchUser( - title = "Saved Messages", - username = "", - publicKey = accountPublicKey, - verified = 0, - online = 1 - ) - ) - ) - }, - onSettingsClick = { pushScreen(Screen.Profile) }, - onInviteFriendsClick = { - // TODO: Share invite link - }, - onSearchClick = { pushScreen(Screen.Search) }, - onRequestsClick = { pushScreen(Screen.Requests) }, - onNewChat = { - // TODO: Show new chat screen - }, - onUserSelect = { selectedChatUser -> - pushScreen(Screen.ChatDetail(selectedChatUser)) - }, - backgroundBlurColorId = backgroundBlurColorId, - pinnedChats = pinnedChats, - onTogglePin = { opponentKey -> - mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } - }, - chatsViewModel = chatsListViewModel, - avatarRepository = avatarRepository, - onAddAccount = { - onAddAccount() - }, - onSwitchAccount = onSwitchAccount, - onDeleteAccountFromSidebar = onDeleteAccountFromSidebar - ) + SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize()) { + ChatsListScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountUsername = accountUsername, + accountVerified = accountVerified, + accountPhone = accountPhone, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + privateKeyHash = privateKeyHash, + onToggleTheme = onToggleTheme, + onProfileClick = { pushScreen(Screen.Profile) }, + onNewGroupClick = { + // TODO: Navigate to new group + }, + onContactsClick = { + // TODO: Navigate to contacts + }, + onCallsClick = { + // TODO: Navigate to calls + }, + onSavedMessagesClick = { + // Открываем чат с самим собой (Saved Messages) + pushScreen( + Screen.ChatDetail( + SearchUser( + title = "Saved Messages", + username = "", + publicKey = accountPublicKey, + verified = 0, + online = 1 + ) + ) + ) + }, + onSettingsClick = { pushScreen(Screen.Profile) }, + onInviteFriendsClick = { + // TODO: Share invite link + }, + onSearchClick = { pushScreen(Screen.Search) }, + onRequestsClick = { pushScreen(Screen.Requests) }, + onNewChat = { + // TODO: Show new chat screen + }, + onUserSelect = { selectedChatUser -> + pushScreen(Screen.ChatDetail(selectedChatUser)) + }, + backgroundBlurColorId = backgroundBlurColorId, + pinnedChats = pinnedChats, + onTogglePin = { opponentKey -> + mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } + }, + chatsViewModel = chatsListViewModel, + avatarRepository = avatarRepository, + onAddAccount = { + onAddAccount() + }, + onSwitchAccount = onSwitchAccount, + onDeleteAccountFromSidebar = onDeleteAccountFromSidebar + ) + } SwipeBackContainer( isVisible = isRequestsVisible, @@ -935,7 +938,8 @@ fun MainScreen( isVisible = selectedUser != null, onBack = { popChatAndChildren() }, isDarkTheme = isDarkTheme, - swipeEnabled = !isChatSwipeLocked + swipeEnabled = !isChatSwipeLocked, + propagateBackgroundProgress = false ) { selectedUser?.let { currentChatUser -> // Экран чата 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 9c7aa53..2057292 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 @@ -9,6 +9,7 @@ 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.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -32,6 +33,8 @@ private const val EDGE_ZONE_DP = 320 private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f private const val TOUCH_SLOP_FACTOR = 0.35f private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f +private const val BACKGROUND_MIN_SCALE = 0.94f +private const val BACKGROUND_PARALLAX_DP = 18 /** * Telegram-style swipe back container (optimized) @@ -45,12 +48,43 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f * - Shadow on left edge during swipe * - Low completion threshold for comfortable one-handed back swipe */ +private object SwipeBackSharedProgress { + var ownerId by mutableLongStateOf(Long.MIN_VALUE) + var progress by mutableFloatStateOf(0f) + var active by mutableStateOf(false) +} + +@Composable +fun SwipeBackBackgroundEffect( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + val density = LocalDensity.current + val progress = SwipeBackSharedProgress.progress.coerceIn(0f, 1f) + val active = SwipeBackSharedProgress.active + val parallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } + val scale = if (active) BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * progress else 1f + val backgroundTranslationX = if (active) -parallaxPx * (1f - progress) else 0f + + Box( + modifier = + modifier.graphicsLayer { + transformOrigin = TransformOrigin(0f, 0.5f) + scaleX = scale + scaleY = scale + translationX = backgroundTranslationX + }, + content = content + ) +} + @Composable fun SwipeBackContainer( isVisible: Boolean, onBack: () -> Unit, isDarkTheme: Boolean, swipeEnabled: Boolean = true, + propagateBackgroundProgress: Boolean = true, content: @Composable () -> Unit ) { // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. @@ -64,6 +98,8 @@ fun SwipeBackContainer( val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) + val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } + val containerId = remember { System.identityHashCode(Any()).toLong() } // Animation state for swipe (used only for swipe animations, not during drag) val offsetAnimatable = remember { Animatable(0f) } @@ -97,6 +133,38 @@ fun SwipeBackContainer( // Scrim alpha based on swipe progress val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f + val sharedOwnerId = SwipeBackSharedProgress.ownerId + val sharedProgress = SwipeBackSharedProgress.progress + val sharedActive = SwipeBackSharedProgress.active + val isBackgroundForActiveSwipe = sharedActive && sharedOwnerId != containerId + val backgroundScale = + if (isBackgroundForActiveSwipe) { + BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * sharedProgress.coerceIn(0f, 1f) + } else { + 1f + } + val backgroundTranslationX = + if (isBackgroundForActiveSwipe) { + -backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f)) + } else { + 0f + } + + fun updateSharedSwipeProgress(progress: Float, active: Boolean) { + if (!propagateBackgroundProgress) return + SwipeBackSharedProgress.ownerId = containerId + SwipeBackSharedProgress.progress = progress.coerceIn(0f, 1f) + SwipeBackSharedProgress.active = active + } + + fun clearSharedSwipeProgressIfOwner() { + if (!propagateBackgroundProgress) return + if (SwipeBackSharedProgress.ownerId == containerId) { + SwipeBackSharedProgress.ownerId = Long.MIN_VALUE + SwipeBackSharedProgress.progress = 0f + SwipeBackSharedProgress.active = false + } + } // Handle visibility changes // 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if @@ -134,13 +202,26 @@ fun SwipeBackContainer( shouldShow = false isAnimatingOut = false dragOffset = 0f + clearSharedSwipeProgressIfOwner() } } } + DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } } + if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier.fillMaxSize().graphicsLayer { + if (isBackgroundForActiveSwipe) { + transformOrigin = TransformOrigin(0f, 0.5f) + scaleX = backgroundScale + scaleY = backgroundScale + translationX = backgroundTranslationX + } + } + ) { // Scrim (dimming layer behind the screen) - only when swiping if (currentOffset > 0f) { Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) @@ -225,6 +306,12 @@ fun SwipeBackContainer( startedSwipe = true isDragging = true dragOffset = offsetAnimatable.value + updateSharedSwipeProgress( + progress = + dragOffset / + screenWidthPx, + active = true + ) val imm = context.getSystemService( @@ -251,6 +338,12 @@ fun SwipeBackContainer( 0f, screenWidthPx ) + updateSharedSwipeProgress( + progress = + dragOffset / + screenWidthPx, + active = true + ) velocityTracker.addPosition( change.uptimeMillis, change.position @@ -281,6 +374,12 @@ fun SwipeBackContainer( scope.launch { offsetAnimatable.snapTo(dragOffset) + updateSharedSwipeProgress( + progress = + dragOffset / + screenWidthPx, + active = true + ) if (shouldComplete) { offsetAnimatable.animateTo( @@ -291,7 +390,15 @@ fun SwipeBackContainer( ANIMATION_DURATION_EXIT, easing = TelegramEasing - ) + ), + block = { + updateSharedSwipeProgress( + progress = + value / + screenWidthPx, + active = true + ) + } ) // 🔥 FIX: Reset state BEFORE onBack() to prevent // redundant fade-out animation in LaunchedEffect. @@ -300,6 +407,7 @@ fun SwipeBackContainer( // overlay blocks all touches on the chat list. shouldShow = false dragOffset = 0f + clearSharedSwipeProgressIfOwner() onBack() } else { offsetAnimatable.animateTo( @@ -310,9 +418,18 @@ fun SwipeBackContainer( ANIMATION_DURATION_EXIT, easing = TelegramEasing - ) + ), + block = { + updateSharedSwipeProgress( + progress = + value / + screenWidthPx, + active = true + ) + } ) dragOffset = 0f + clearSharedSwipeProgressIfOwner() } } }