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.RequestsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache 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.components.SwipeBackContainer
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
@@ -688,65 +689,67 @@ fun MainScreen(
// 🔥 Простая навигация с swipe back // 🔥 Простая навигация с swipe back
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
ChatsListScreen( SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize()) {
isDarkTheme = isDarkTheme, ChatsListScreen(
accountName = accountName, isDarkTheme = isDarkTheme,
accountUsername = accountUsername, accountName = accountName,
accountVerified = accountVerified, accountUsername = accountUsername,
accountPhone = accountPhone, accountVerified = accountVerified,
accountPublicKey = accountPublicKey, accountPhone = accountPhone,
accountPrivateKey = accountPrivateKey, accountPublicKey = accountPublicKey,
privateKeyHash = privateKeyHash, accountPrivateKey = accountPrivateKey,
onToggleTheme = onToggleTheme, privateKeyHash = privateKeyHash,
onProfileClick = { pushScreen(Screen.Profile) }, onToggleTheme = onToggleTheme,
onNewGroupClick = { onProfileClick = { pushScreen(Screen.Profile) },
// TODO: Navigate to new group onNewGroupClick = {
}, // TODO: Navigate to new group
onContactsClick = { },
// TODO: Navigate to contacts onContactsClick = {
}, // TODO: Navigate to contacts
onCallsClick = { },
// TODO: Navigate to calls onCallsClick = {
}, // TODO: Navigate to calls
onSavedMessagesClick = { },
// Открываем чат с самим собой (Saved Messages) onSavedMessagesClick = {
pushScreen( // Открываем чат с самим собой (Saved Messages)
Screen.ChatDetail( pushScreen(
SearchUser( Screen.ChatDetail(
title = "Saved Messages", SearchUser(
username = "", title = "Saved Messages",
publicKey = accountPublicKey, username = "",
verified = 0, publicKey = accountPublicKey,
online = 1 verified = 0,
) online = 1
) )
) )
}, )
onSettingsClick = { pushScreen(Screen.Profile) }, },
onInviteFriendsClick = { onSettingsClick = { pushScreen(Screen.Profile) },
// TODO: Share invite link onInviteFriendsClick = {
}, // TODO: Share invite link
onSearchClick = { pushScreen(Screen.Search) }, },
onRequestsClick = { pushScreen(Screen.Requests) }, onSearchClick = { pushScreen(Screen.Search) },
onNewChat = { onRequestsClick = { pushScreen(Screen.Requests) },
// TODO: Show new chat screen onNewChat = {
}, // TODO: Show new chat screen
onUserSelect = { selectedChatUser -> },
pushScreen(Screen.ChatDetail(selectedChatUser)) onUserSelect = { selectedChatUser ->
}, pushScreen(Screen.ChatDetail(selectedChatUser))
backgroundBlurColorId = backgroundBlurColorId, },
pinnedChats = pinnedChats, backgroundBlurColorId = backgroundBlurColorId,
onTogglePin = { opponentKey -> pinnedChats = pinnedChats,
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } onTogglePin = { opponentKey ->
}, mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
chatsViewModel = chatsListViewModel, },
avatarRepository = avatarRepository, chatsViewModel = chatsListViewModel,
onAddAccount = { avatarRepository = avatarRepository,
onAddAccount() onAddAccount = {
}, onAddAccount()
onSwitchAccount = onSwitchAccount, },
onDeleteAccountFromSidebar = onDeleteAccountFromSidebar onSwitchAccount = onSwitchAccount,
) onDeleteAccountFromSidebar = onDeleteAccountFromSidebar
)
}
SwipeBackContainer( SwipeBackContainer(
isVisible = isRequestsVisible, isVisible = isRequestsVisible,
@@ -935,7 +938,8 @@ fun MainScreen(
isVisible = selectedUser != null, isVisible = selectedUser != null,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
swipeEnabled = !isChatSwipeLocked swipeEnabled = !isChatSwipeLocked,
propagateBackgroundProgress = false
) { ) {
selectedUser?.let { currentChatUser -> selectedUser?.let { currentChatUser ->
// Экран чата // Экран чата

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.input.pointer.util.VelocityTracker 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 EDGE_ZONE_WIDTH_FRACTION = 0.85f
private const val TOUCH_SLOP_FACTOR = 0.35f private const val TOUCH_SLOP_FACTOR = 0.35f
private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f 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) * Telegram-style swipe back container (optimized)
@@ -45,12 +48,43 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
* - Shadow on left edge during swipe * - Shadow on left edge during swipe
* - Low completion threshold for comfortable one-handed back 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 @Composable
fun SwipeBackContainer( fun SwipeBackContainer(
isVisible: Boolean, isVisible: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 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 screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) 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) // Animation state for swipe (used only for swipe animations, not during drag)
val offsetAnimatable = remember { Animatable(0f) } val offsetAnimatable = remember { Animatable(0f) }
@@ -97,6 +133,38 @@ fun SwipeBackContainer(
// Scrim alpha based on swipe progress // Scrim alpha based on swipe progress
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f 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 // Handle visibility changes
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if // 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
@@ -134,13 +202,26 @@ fun SwipeBackContainer(
shouldShow = false shouldShow = false
isAnimatingOut = false isAnimatingOut = false
dragOffset = 0f dragOffset = 0f
clearSharedSwipeProgressIfOwner()
} }
} }
} }
DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } }
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return 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 // Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) { if (currentOffset > 0f) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
@@ -225,6 +306,12 @@ fun SwipeBackContainer(
startedSwipe = true startedSwipe = true
isDragging = true isDragging = true
dragOffset = offsetAnimatable.value dragOffset = offsetAnimatable.value
updateSharedSwipeProgress(
progress =
dragOffset /
screenWidthPx,
active = true
)
val imm = val imm =
context.getSystemService( context.getSystemService(
@@ -251,6 +338,12 @@ fun SwipeBackContainer(
0f, 0f,
screenWidthPx screenWidthPx
) )
updateSharedSwipeProgress(
progress =
dragOffset /
screenWidthPx,
active = true
)
velocityTracker.addPosition( velocityTracker.addPosition(
change.uptimeMillis, change.uptimeMillis,
change.position change.position
@@ -281,6 +374,12 @@ fun SwipeBackContainer(
scope.launch { scope.launch {
offsetAnimatable.snapTo(dragOffset) offsetAnimatable.snapTo(dragOffset)
updateSharedSwipeProgress(
progress =
dragOffset /
screenWidthPx,
active = true
)
if (shouldComplete) { if (shouldComplete) {
offsetAnimatable.animateTo( offsetAnimatable.animateTo(
@@ -291,7 +390,15 @@ fun SwipeBackContainer(
ANIMATION_DURATION_EXIT, ANIMATION_DURATION_EXIT,
easing = easing =
TelegramEasing TelegramEasing
) ),
block = {
updateSharedSwipeProgress(
progress =
value /
screenWidthPx,
active = true
)
}
) )
// 🔥 FIX: Reset state BEFORE onBack() to prevent // 🔥 FIX: Reset state BEFORE onBack() to prevent
// redundant fade-out animation in LaunchedEffect. // redundant fade-out animation in LaunchedEffect.
@@ -300,6 +407,7 @@ fun SwipeBackContainer(
// overlay blocks all touches on the chat list. // overlay blocks all touches on the chat list.
shouldShow = false shouldShow = false
dragOffset = 0f dragOffset = 0f
clearSharedSwipeProgressIfOwner()
onBack() onBack()
} else { } else {
offsetAnimatable.animateTo( offsetAnimatable.animateTo(
@@ -310,9 +418,18 @@ fun SwipeBackContainer(
ANIMATION_DURATION_EXIT, ANIMATION_DURATION_EXIT,
easing = easing =
TelegramEasing TelegramEasing
) ),
block = {
updateSharedSwipeProgress(
progress =
value /
screenWidthPx,
active = true
)
}
) )
dragOffset = 0f dragOffset = 0f
clearSharedSwipeProgressIfOwner()
} }
} }
} }