feat: improve swipe back scale behavior across screens

This commit is contained in:
2026-02-27 23:56:21 +05:00
parent 507c26d3c6
commit 3f2b52b578
2 changed files with 184 additions and 63 deletions

View File

@@ -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 ->
// Экран чата

View File

@@ -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()
}
}
}