diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt index 6f513c1ead..1cb2a5ac99 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt @@ -348,11 +348,13 @@ class MainNavigationLaunchTest { await(description = "no new ConversationFragment after Empty detail intent") { recorder.createdArgs.size == baseline } - // The user-visible signal that we're "back on the list" is the chat list fragment - // being attached, not just the VM saying CHATS. - awaitListFragment(launched, MainNavigationListLocation.CHATS) val vm = runOnMainSync { launched.activity.mainNavigationViewModel() } + + await(description = "conversation cleared from chats back stack after Empty detail intent") { + vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation } + } + check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) { "Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 854157e77f..c315782a8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -70,6 +70,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.subjects.PublishSubject @@ -83,6 +85,7 @@ import kotlinx.coroutines.withContext import org.signal.core.ui.BottomSheetUtil import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.ui.navigation.TransitionSpecs import org.signal.core.ui.permissions.Permissions import org.signal.core.ui.rememberIsSplitPane import org.signal.core.util.AppForegroundObserver @@ -103,6 +106,8 @@ import org.thoughtcrime.securesms.calls.log.CallLogFragment import org.thoughtcrime.securesms.calls.new.NewCallActivity import org.thoughtcrime.securesms.calls.quality.CallQuality import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment +import org.thoughtcrime.securesms.chats.ConversationTransitionState +import org.thoughtcrime.securesms.chats.chatsNavEntries import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet @@ -132,7 +137,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity -import org.thoughtcrime.securesms.main.ChatNavGraphState import org.thoughtcrime.securesms.main.DetailsScreenNavHost import org.thoughtcrime.securesms.main.MainBottomChrome import org.thoughtcrime.securesms.main.MainBottomChromeCallback @@ -141,7 +145,6 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData import org.thoughtcrime.securesms.main.MainMegaphoneState import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationDetailLocation -import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationRouter @@ -155,7 +158,6 @@ import org.thoughtcrime.securesms.main.MainToolbarState import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder import org.thoughtcrime.securesms.main.callNavGraphBuilder -import org.thoughtcrime.securesms.main.chatNavGraphBuilder import org.thoughtcrime.securesms.main.navigateToDetailLocation import org.thoughtcrime.securesms.main.rememberDetailNavHostController import org.thoughtcrime.securesms.main.rememberFocusRequester @@ -494,18 +496,15 @@ class MainActivity : } } - val chatNavGraphState = ChatNavGraphState.remember(isSplitPane) + val convoTransitionState = ConversationTransitionState.remember(isSplitPane) val mutableInteractionSource = remember { MutableInteractionSource() } - MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap) - val chatsNavHostController = rememberDetailNavHostController( - onRequestFocus = rememberFocusRequester( - mainNavigationViewModel = mainNavigationViewModel, - currentListLocation = mainNavigationState.currentListLocation, - isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) } - ) - ) { - chatNavGraphBuilder(chatNavGraphState) + LaunchedEffect(convoTransitionState) { + mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() } + } + + LaunchedEffect(isSplitPane) { + mainNavigationViewModel.onSplitPaneChanged(isSplitPane) } val callsNavHostController = rememberDetailNavHostController( @@ -527,22 +526,23 @@ class MainActivity : } LaunchedEffect(Unit) { - suspend fun navigateToLocation(location: MainNavigationDetailLocation) { + fun navigateToLocation(location: MainNavigationDetailLocation) { when (location) { is MainNavigationDetailLocation.Empty -> { when (mainNavigationState.currentListLocation) { - MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController + MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> { + throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.") + } + MainNavigationListLocation.CALLS -> callsNavHostController MainNavigationListLocation.STORIES -> storiesNavHostController }.navigateToDetailLocation(location) } - is MainNavigationDetailLocation.Conversation -> { - chatNavGraphState.writeGraphicsLayerToBitmap() - chatsNavHostController.navigateToDetailLocation(location) + is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> { + throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.") } - is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location) is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location) is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location) is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location) @@ -556,6 +556,7 @@ class MainActivity : } val scope = rememberCoroutineScope() + BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) { mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty) scope.launch { @@ -606,6 +607,14 @@ class MainActivity : } } + LaunchedEffect(wrappedNavigator.scaffoldValue.primary) { + if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Hidden && + mainNavigationState.currentListLocation.isChatsTab + ) { + mainNavigationViewModel.onChatsDetailPaneCollapsed() + } + } + val noEnterTransitionFactory = remember { AppScaffoldAnimationStateFactory( enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot { @@ -616,7 +625,7 @@ class MainActivity : AppScaffold( navigator = wrappedNavigator, - modifier = chatNavGraphState.writeContentToGraphicsLayer(), + modifier = convoTransitionState.writeContentToGraphicsLayer(), paneExpansionState = paneExpansionState, contentWindowInsets = WindowInsets(), snackbarHost = { @@ -727,10 +736,16 @@ class MainActivity : primaryContent = { when (mainNavigationState.currentListLocation) { MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> { - DetailsScreenNavHost( - navHostController = chatsNavHostController, - contentLayoutData = contentLayoutData - ) + if (mainNavigationViewModel.chatsBackStackEntries.isNotEmpty()) { + NavDisplay( + backStack = mainNavigationViewModel.chatsBackStackEntries, + onBack = { mainNavigationViewModel.popChatsDetailLocation() }, + transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec, + popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec, + predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec, + entryProvider = entryProvider { chatsNavEntries(convoTransitionState) } + ) + } } MainNavigationListLocation.CALLS -> { @@ -758,7 +773,7 @@ class MainActivity : } else { null }, - animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) { + animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) { noEnterTransitionFactory } else { AppScaffoldAnimationStateFactory.Default diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt new file mode 100644 index 0000000000..d85d80e1e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.chats + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi +import androidx.lifecycle.viewmodel.compose.saveable +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation + +/** + * Controls the navigation stack used by the chats screen. + */ +@OptIn(SavedStateHandleSaveableApi::class) +class ChatsBackStack(savedStateHandle: SavedStateHandle) { + + companion object { + private const val KEY = "chats_back_stack" + + val saver: Saver, ArrayList> = Saver( + save = { ArrayList(it) }, + restore = { mutableStateListOf(*it.toTypedArray()) } + ) + } + + val entries: SnapshotStateList = savedStateHandle.saveable( + key = KEY, + saver = saver + ) { + mutableStateListOf() + } + + val activeConversationThreadId: Long? + get() = entries + .filterIsInstance() + .lastOrNull() + ?.controllerKey + + val hasConversation: Boolean + get() = entries.any { it is MainNavigationDetailLocation.Conversation } + + /** + * Pushes an entry onto the stack. + */ + fun push(location: MainNavigationDetailLocation) { + when (location) { + is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit + + is MainNavigationDetailLocation.Conversation -> { + entries.removeAll { it !is MainNavigationDetailLocation.Empty } + entries.add(location) + } + + else -> entries.add(location) + } + } + + /** + * Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root. + */ + fun pop(): Boolean { + if (entries.size <= 1) return false + entries.removeAt(entries.lastIndex) + return true + } + + /** + * Resets the stack to its base empty state. + */ + fun reset(isSplitPane: Boolean) { + entries.clear() + if (isSplitPane) { + entries.add(MainNavigationDetailLocation.Empty) + } + } + + /** + * Ensures that [MainNavigationDetailLocation.Empty] is present in the stack iff isSplitPane=true. + */ + fun updateEmptyDetailForPaneMode(isSplitPane: Boolean) { + val hasEmptyBase = entries.firstOrNull() is MainNavigationDetailLocation.Empty + when { + isSplitPane && !hasEmptyBase -> entries.add(0, MainNavigationDetailLocation.Empty) + !isSplitPane && hasEmptyBase -> entries.removeAll { it is MainNavigationDetailLocation.Empty } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt new file mode 100644 index 0000000000..bed12b9c05 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.chats + +import android.os.Bundle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.signal.core.ui.navigation.TransitionSpecs +import org.thoughtcrime.securesms.MainNavigator +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment +import org.thoughtcrime.securesms.compose.FragmentBackHandler +import org.thoughtcrime.securesms.compose.FragmentBackPressedState +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.v2.ConversationFragment +import org.thoughtcrime.securesms.main.EmptyDetailScreen +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation +import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment + +fun EntryProviderScope.chatsNavEntries( + transitionState: ConversationTransitionState +) { + entry { + NoConvoSelectedEntry() + } + + entry( + // disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode. + metadata = TransitionSpecs.None.metadata + ) { route -> + ConversationEntry(route, transitionState) + } + + entry { route -> + MessageDetailsEntry(route) + } + + entry { route -> + ConversationSettingsEntry(route) + } +} + +@Composable +private fun NoConvoSelectedEntry() { + EmptyDetailScreen() +} + +@Composable +private fun ConversationEntry( + route: MainNavigationDetailLocation.Conversation, + transitionState: ConversationTransitionState +) { + val context = LocalContext.current + val navigatorProvider = context as? MainNavigator.NavigatorProvider + val fragmentState = key(route) { rememberFragmentState() } + val arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { + "Handed null Conversation intent arguments." + } + + val fragmentContentReady = remember { MutableStateFlow(false) } + val backPressedState = remember { FragmentBackPressedState() } + FragmentBackHandler(backPressedState) + + ConversationLoadingMask( + transitionState = transitionState, + contentReady = fragmentContentReady, + onFirstRender = { navigatorProvider?.onFirstRender() } + ) { modifier -> + AndroidFragment( + clazz = ConversationFragment::class.java, + fragmentState = fragmentState, + arguments = arguments, + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + ) { fragment -> + backPressedState.attach(fragment) + + fragment.viewLifecycleOwner.lifecycleScope.launch { + fragment.repeatOnLifecycle(Lifecycle.State.STARTED) { + fragment.didFirstFrameRender.collectLatest { fragmentContentReady.value = it } + } + } + } + } +} + +@Composable +private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) { + val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider + val fragmentState = key(route) { rememberFragmentState() } + + LaunchedEffect(Unit) { + navigatorProvider?.onFirstRender() + } + + AndroidFragment( + clazz = MessageDetailsFragment::class.java, + fragmentState = fragmentState, + arguments = MessageDetailsFragment.args(route.recipientId, route.messageId), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .navigationBarsPadding() + ) +} + +@Composable +private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) { + val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider + val fragmentState = key(route) { rememberFragmentState() } + val arguments: Bundle? by produceState(null, route.recipientId) { + value = ConversationSettingsNavHostFragment.createArgs(route.recipientId) + } + + LaunchedEffect(Unit) { + navigatorProvider?.onFirstRender() + } + + arguments?.let { args -> + val backPressedState = remember { FragmentBackPressedState() } + FragmentBackHandler(backPressedState) + + AndroidFragment( + clazz = ConversationSettingsNavHostFragment::class.java, + fragmentState = fragmentState, + arguments = args, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .navigationBarsPadding() + ) { fragment -> + backPressedState.attach(fragment) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationLoadingMask.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationLoadingMask.kt new file mode 100644 index 0000000000..f2936f3551 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationLoadingMask.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.chats + +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults +import org.thoughtcrime.securesms.window.AppScaffoldAnimationState +import kotlin.time.Duration.Companion.seconds + +/** + * Wraps [content] with an animation that crossfades a snapshotted chat list bitmap over the conversation fragment while it loads its first frame. + * + * @param contentReady emits when the fragment's first frame has been rendered. + * @param onFirstRender signals that this composable has content ready to display, so the parent activity can proceed with its first draw. + * @param content will be animated in as the overlay fades out. + */ +@Composable +fun ConversationLoadingMask( + transitionState: ConversationTransitionState, + contentReady: StateFlow, + onFirstRender: () -> Unit, + content: @Composable (chatModifier: Modifier) -> Unit +) { + // it can take a long time to load content, so we use a "fake" chat list image to delay displaying the fragment + // and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation. + var shouldDisplayFragment by remember { + val hasBitmap = transitionState.chatBitmap != null + mutableStateOf(!hasBitmap) + } + val transition: Transition = updateTransition(shouldDisplayFragment) + val bitmap = transitionState.chatBitmap + + val fakeChatListAnimationState = transition.fakeChatListAnimationState() + val chatAnimationState = transition.chatAnimationState(hasFake = bitmap != null) + + LaunchedEffect(transition.currentState, transition.isRunning) { + if (transition.currentState && !transition.isRunning) { + transitionState.clearBitmap() + } + } + + LaunchedEffect(shouldDisplayFragment) { + onFirstRender() + } + + LaunchedEffect(contentReady) { + if (!shouldDisplayFragment) { + withTimeoutOrNull(5.seconds) { + contentReady.first { it } + } + shouldDisplayFragment = true + } + } + + val chatModifier = Modifier.graphicsLayer { + with(chatAnimationState) { applyChildValues() } + } + + Box(modifier = Modifier.fillMaxSize()) { + content(chatModifier) + + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier + .graphicsLayer { + with(fakeChatListAnimationState) { applyChildValues() } + } + .fillMaxSize() + ) + } + } +} + +@Composable +private fun Transition.fakeChatListAnimationState(): AppScaffoldAnimationState { + val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f } + val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp } + return remember { + AppScaffoldAnimationState( + offset = offset, + alpha = alpha + ) + } +} + +@Composable +private fun Transition.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState { + val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f } + return if (!hasFake) { + remember { + AppScaffoldAnimationState( + offset = mutableStateOf(0.dp), + alpha = alpha + ) + } + } else { + val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp } + remember { + AppScaffoldAnimationState( + offset = offset, + alpha = alpha + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationTransitionState.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationTransitionState.kt new file mode 100644 index 0000000000..2d5ca08fca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ConversationTransitionState.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.chats + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer + +/** + * Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations + * in lieu of proper support for postponing enter transitions. + */ +@Stable +class ConversationTransitionState private constructor( + val isSplitPane: Boolean, + val graphicsLayer: GraphicsLayer +) { + companion object { + @Composable + fun remember(isSplitPane: Boolean): ConversationTransitionState { + val graphicsLayer = rememberGraphicsLayer() + + return remember(isSplitPane) { + ConversationTransitionState(isSplitPane, graphicsLayer) + } + } + } + + var chatBitmap: ImageBitmap? by mutableStateOf(null) + private set + + private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false) + + suspend fun writeGraphicsLayerToBitmap() { + // toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below + if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) { + chatBitmap = graphicsLayer.toImageBitmap() + } + } + + fun writeContentToGraphicsLayer(): Modifier { + if (isSplitPane) return Modifier + + return Modifier.drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + hasWrittenToGraphicsLayer = true + } + + drawLayer(graphicsLayer) + } + } + + fun clearBitmap() { + chatBitmap = null + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt deleted file mode 100644 index 7712a08833..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.main - -import android.os.Build -import android.os.Bundle -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.layer.GraphicsLayer -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.unit.dp -import androidx.fragment.compose.AndroidFragment -import androidx.fragment.compose.rememberFragmentState -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.signal.core.ui.rememberIsSplitPane -import org.thoughtcrime.securesms.MainNavigator -import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment -import org.thoughtcrime.securesms.compose.FragmentBackHandler -import org.thoughtcrime.securesms.compose.FragmentBackPressedState -import org.thoughtcrime.securesms.conversation.ConversationArgs -import org.thoughtcrime.securesms.conversation.ConversationIntents -import org.thoughtcrime.securesms.conversation.v2.ConversationFragment -import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment -import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.serialization.JsonSerializableNavType -import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults -import org.thoughtcrime.securesms.window.AppScaffoldAnimationState -import kotlin.reflect.typeOf -import kotlin.time.Duration.Companion.milliseconds - -private val conversationArgsType = typeOf() -private val recipientIdType = typeOf() -private val messageIdType = typeOf() - -fun NavGraphBuilder.chatNavGraphBuilder( - chatNavGraphState: ChatNavGraphState -) { - composable { - if (LocalResources.current.rememberIsSplitPane()) { - EmptyDetailScreen() - } - } - - composable( - typeMap = mapOf( - conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer()) - ) - ) { navBackStackEntry -> - val route = navBackStackEntry.toRoute() - val fragmentState = key(route) { rememberFragmentState() } - val context = LocalContext.current - - // Because it can take a long time to load content, we use a "fake" chat list image to delay displaying - // the fragment and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation. - var shouldDisplayFragment by remember { mutableStateOf(chatNavGraphState.chatBitmap == null) } - val transition: Transition = updateTransition(shouldDisplayFragment) - val bitmap = chatNavGraphState.chatBitmap - - val fakeChatListAnimationState = transition.fakeChatListAnimationState() - val chatAnimationState = transition.chatAnimationState(bitmap != null) - - LaunchedEffect(transition.currentState, transition.isRunning) { - if (transition.currentState && !transition.isRunning) { - chatNavGraphState.clearBitmap() - } - } - - LaunchedEffect(shouldDisplayFragment) { - (context as? MainNavigator.NavigatorProvider)?.onFirstRender() - } - - if (bitmap != null) { - Image( - bitmap = bitmap, - contentDescription = null, - modifier = Modifier - .graphicsLayer { - with(fakeChatListAnimationState) { - applyChildValues() - } - } - .fillMaxSize() - ) - } - - val backPressedState = remember { FragmentBackPressedState() } - FragmentBackHandler(backPressedState) - - AndroidFragment( - clazz = ConversationFragment::class.java, - fragmentState = fragmentState, - arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." }, - modifier = Modifier - .graphicsLayer { - with(chatAnimationState) { - applyChildValues() - } - } - .background(MaterialTheme.colorScheme.background) - .fillMaxSize() - ) { fragment -> - backPressedState.attach(fragment) - - fragment.viewLifecycleOwner.lifecycleScope.launch { - fragment.repeatOnLifecycle(Lifecycle.State.STARTED) { - fragment.didFirstFrameRender.collectLatest { - if (!shouldDisplayFragment) { - shouldDisplayFragment = it - if (!it) { - delay(150.milliseconds) - shouldDisplayFragment = true - } - } - } - } - } - } - } - - composable( - typeMap = mapOf( - recipientIdType to JsonSerializableNavType(RecipientId.serializer()), - messageIdType to MessageId.NavType() - ) - ) { navBackStackEntry -> - val context = LocalContext.current - val route = navBackStackEntry.toRoute() - val fragmentState = key(route) { rememberFragmentState() } - - LaunchedEffect(Unit) { - (context as? MainNavigator.NavigatorProvider)?.onFirstRender() - } - - AndroidFragment( - clazz = MessageDetailsFragment::class.java, - fragmentState = fragmentState, - arguments = MessageDetailsFragment.args(route.recipientId, route.messageId), - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding() - .navigationBarsPadding() - ) - } - - composable( - typeMap = mapOf( - recipientIdType to JsonSerializableNavType(RecipientId.serializer()) - ) - ) { navBackStackEntry -> - - val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider - val route = navBackStackEntry.toRoute() - val fragmentState = key(route) { rememberFragmentState() } - val arguments: Bundle? by produceState(null, route.recipientId) { - value = ConversationSettingsNavHostFragment.createArgs(route.recipientId) - } - - LaunchedEffect(Unit) { - navigatorProvider?.onFirstRender() - } - - arguments?.let { args -> - val backPressedState = remember { FragmentBackPressedState() } - FragmentBackHandler(backPressedState) - - AndroidFragment( - clazz = ConversationSettingsNavHostFragment::class.java, - fragmentState = fragmentState, - arguments = args, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding() - .navigationBarsPadding() - ) { fragment -> - backPressedState.attach(fragment) - } - } - } -} - -@Composable -private fun Transition.fakeChatListAnimationState(): AppScaffoldAnimationState { - val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f } - val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp } - - return remember { - AppScaffoldAnimationState( - offset = offset, - alpha = alpha - ) - } -} - -@Composable -private fun Transition.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState { - val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f } - - return if (!hasFake) { - remember { - AppScaffoldAnimationState( - offset = mutableStateOf(0.dp), - alpha = alpha - ) - } - } else { - val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp } - - remember { - AppScaffoldAnimationState( - offset = offset, - alpha = alpha - ) - } - } -} - -/** - * Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations - * in lieu of proper support for postponing enter transitions. - */ -@Stable -class ChatNavGraphState private constructor( - val isSplitPane: Boolean, - val graphicsLayer: GraphicsLayer -) { - companion object { - @Composable - fun remember(isSplitPane: Boolean): ChatNavGraphState { - val graphicsLayer = rememberGraphicsLayer() - - return remember(isSplitPane) { - ChatNavGraphState( - isSplitPane, - graphicsLayer - ) - } - } - } - - var chatBitmap: ImageBitmap? by mutableStateOf(null) - private set - - private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false) - - suspend fun writeGraphicsLayerToBitmap() { - // toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below - if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) { - chatBitmap = graphicsLayer.toImageBitmap() - } - } - - fun writeContentToGraphicsLayer(): Modifier { - return Modifier.drawWithContent { - graphicsLayer.record { - this@drawWithContent.drawContent() - hasWrittenToGraphicsLayer = true - } - - drawLayer(graphicsLayer) - } - } - - fun clearBitmap() { - chatBitmap = null - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt index e30ed65a27..84034df524 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt @@ -81,7 +81,10 @@ enum class MainNavigationListLocation( STORIES( label = R.string.ConversationListTabs__stories, icon = R.raw.stories_28 - ) + ); + + val isChatsTab: Boolean + get() = this == CHATS || this == ARCHIVE } data class MainNavigationState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 887c4182ad..ebf2e890e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.main import android.os.Parcelable import androidx.compose.runtime.saveable.SaverScope +import androidx.navigation3.runtime.NavKey import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -23,7 +24,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId */ @Serializable @Parcelize -sealed interface MainNavigationDetailLocation : Parcelable { +sealed interface MainNavigationDetailLocation : Parcelable, NavKey { class Saver( val earlyLocation: MainNavigationDetailLocation? diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index cdbde08bf2..ac9cd8d23a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -6,9 +6,12 @@ package org.thoughtcrime.securesms.main import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -34,6 +37,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.calls.log.CallLogRow +import org.thoughtcrime.securesms.chats.ChatsBackStack import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -46,6 +50,7 @@ import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.delegate import org.thoughtcrime.securesms.window.AppScaffoldNavigator import java.util.Optional +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalMaterial3AdaptiveApi::class) class MainNavigationViewModel( @@ -74,16 +79,23 @@ class MainNavigationViewModel( private var navigator: AppScaffoldNavigator? = null private var navigatorScope: CoroutineScope? = null + private var captureChatListSnapshot: (suspend () -> Unit)? = null + private var isSplitPane: Boolean = false + + private val chatsBackStack: ChatsBackStack = ChatsBackStack(savedStateHandle) + val chatsBackStackEntries: SnapshotStateList + get() = chatsBackStack.entries + private val internalDetailLocation = MutableSharedFlow() val detailLocation: SharedFlow = internalDetailLocation private val internalIsFullScreenPane = MutableStateFlow(false) val isFullScreenPane: StateFlow = internalIsFullScreenPane - private val internalActiveChatThreadId = MutableStateFlow(-1L) - val observableActiveChatThreadId: Observable = internalActiveChatThreadId.combine(isFullScreenPane) { id, expanded -> - if (expanded) -1L else id - }.asObservable() + val observableActiveChatThreadId: Observable = + snapshotFlow { chatsBackStack.activeConversationThreadId ?: -1L } + .combine(isFullScreenPane) { id, expanded -> if (expanded) -1L else id } + .asObservable() private val internalActiveCallId = MutableStateFlow(null) val observableActiveCallId: Observable> = internalActiveCallId.map { Optional.ofNullable(it) }.combine(isFullScreenPane) { id, expanded -> @@ -153,6 +165,24 @@ class MainNavigationViewModel( internalIsFullScreenPane.update { isFullScreenPane } } + fun setChatListSnapshotCaptureProvider(capture: suspend () -> Unit) { + captureChatListSnapshot = capture + } + + fun onSplitPaneChanged(isSplitPane: Boolean) { + this@MainNavigationViewModel.isSplitPane = isSplitPane + chatsBackStack.updateEmptyDetailForPaneMode(isSplitPane) + + // if no conversation is selected, clear the empty detail pane when switching from split pane to single pane mode. + if (!isSplitPane && + internalMainNavigationState.value.currentListLocation.isChatsTab && + !chatsBackStack.hasConversation && + navigator?.scaffoldValue?.primary == PaneAdaptedValue.Expanded + ) { + navigatorScope?.launch { navigator?.navigateBack() } + } + } + /** * Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation * such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation. @@ -161,18 +191,22 @@ class MainNavigationViewModel( this.navigatorScope = composeScope this.navigator = Nav(threePaneScaffoldNavigator) + val pendingFocus = earlyFocusedPaneRequested + earlyFocusedPaneRequested = null + earlyNavigationListLocationRequested?.let { goTo(it) } earlyNavigationListLocationRequested = null - earlyFocusedPaneRequested?.let { - setFocusedPane(it) + pendingFocus?.let { role -> + if (role == ThreePaneScaffoldRole.Primary) { + lockPaneToSecondary = false + } + setFocusedPane(role) } - earlyFocusedPaneRequested = null - earlyNavigationDetailLocationRequested?.let { detail -> lockPaneToSecondary = false updateActiveStateForLocation(detail) @@ -213,24 +247,10 @@ class MainNavigationViewModel( * This does not update what panel is currently focused, so that we can perform actions (such as first * render) *before* swapping panes. This helps to prevent flashing / duplicate loads. */ - override fun goTo(location: MainNavigationDetailLocation) { - when (location) { - is MainNavigationDetailLocation.Empty, - is MainNavigationDetailLocation.Chats.ConversationSettings, - is MainNavigationDetailLocation.Chats.MessageDetails, - is MainNavigationDetailLocation.CallLinkDetails, - is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> setDetailLocation(location) - - is MainNavigationDetailLocation.Conversation -> goToConversation(location) - } - } + override fun goTo(location: MainNavigationDetailLocation) = setDetailLocation(location) private fun updateActiveStateForLocation(location: MainNavigationDetailLocation) { when (location) { - is MainNavigationDetailLocation.Conversation -> { - internalActiveChatThreadId.update { location.controllerKey } - } - is MainNavigationDetailLocation.CallLinkDetails -> { internalActiveCallId.update { location.controllerKey } } @@ -243,14 +263,14 @@ class MainNavigationViewModel( } } - private fun goToConversation(location: MainNavigationDetailLocation.Conversation) = viewModelScope.launch { - val args = location.conversationArgs + private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation { + val args = conversationArgs val liveRecipient = Recipient.live(args.recipientId) val recipientSnapshot = liveRecipient.get() val wallpaper = recipientSnapshot.wallpaper val updatedArgs = if (recipientSnapshot.isResolving || (wallpaper?.isPhoto == true && !wallpaper.isPrefetched)) { - withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS) { + withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS.milliseconds) { withContext(Dispatchers.Default) { val freshWallpaper = liveRecipient.resolve().wallpaper if (freshWallpaper?.prefetch(AppDependencies.application, NAV_PREFETCH_TIMEOUT_MS) == false) { @@ -266,19 +286,68 @@ class MainNavigationViewModel( args.copy(hasWallpaper = wallpaper != null) } - setDetailLocation(MainNavigationDetailLocation.Conversation(updatedArgs)) + return copy(conversationArgs = updatedArgs) } private fun setDetailLocation(location: MainNavigationDetailLocation) { lockPaneToSecondary = false + val currentListLocation = internalMainNavigationState.value.currentListLocation - if (navigator == null) { - earlyNavigationDetailLocationRequested = location - return + when (location) { + is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> chatsBackStack.reset(isSplitPane) + is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location) + is MainNavigationDetailLocation.Conversation -> goToConversation(location) + + is MainNavigationDetailLocation.Empty, + is MainNavigationDetailLocation.CallLinkDetails, + is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> { + if (navigator == null) { + earlyNavigationDetailLocationRequested = location + return + } + + viewModelScope.launch { + internalDetailLocation.emit(location) + } + } } + } - viewModelScope.launch { - internalDetailLocation.emit(location) + private fun goToConversation(location: MainNavigationDetailLocation.Conversation) { + val captureSnapshot = captureChatListSnapshot + + if (captureSnapshot == null) { + // share intent or process restore - push synchronously, since there's no chat-list snapshot to capture and no need to preload a wallpaper + pushChatsDetailLocation(location) + } else { + viewModelScope.launch { + captureSnapshot() + pushChatsDetailLocation(location.withPreloadedWallpaper()) + } + } + } + + private fun pushChatsDetailLocation(location: MainNavigationDetailLocation) { + chatsBackStack.push(location) + updateActiveStateForLocation(location) + setFocusedPane(ThreePaneScaffoldRole.Primary) + } + + /** + * Inverse of [pushChatsDetailLocation]. Pops the top chats detail entry and, if no conversation + * remains, records the user's intent to stay on the list pane (so a subsequent config change does + * not errantly restore them to the Primary/detail pane). + */ + fun popChatsDetailLocation() { + chatsBackStack.pop() + if (!chatsBackStack.hasConversation) { + lockPaneToSecondary = true + } + } + + fun onChatsDetailPaneCollapsed() { + if (!chatsBackStack.hasConversation) { + chatsBackStack.reset(isSplitPane) } } diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt index aaad5db064..a64b01f765 100644 --- a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt @@ -7,6 +7,8 @@ package org.signal.core.ui.navigation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -17,6 +19,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay import androidx.navigationevent.NavigationEvent /** @@ -106,4 +109,14 @@ object TransitionSpecs { ) + fadeOut(animationSpec = tween(DURATION)) } } + + /** + * No enter/exit animation. + */ + object None { + val metadata: Map = + NavDisplay.transitionSpec { EnterTransition.None togetherWith ExitTransition.None } + + NavDisplay.popTransitionSpec { EnterTransition.None togetherWith ExitTransition.None } + + NavDisplay.predictivePopTransitionSpec { EnterTransition.None togetherWith ExitTransition.None } + } }