From f59597c55f2ea69212966d9baa20fe29aa0abe16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 16:24:17 +0000 Subject: [PATCH] refactor: move skip-slide hint to NavBackStackEntry.savedStateHandle The prior change tracked the "no slide" intent via a mutable flag on Nav that every navigate method had to reset. Replace it with a per-entry hint: navBottomBar writes SKIP_SLIDE_ANIMATION_KEY=true to the new destination's savedStateHandle, and composableFromEnd reads it off targetState (forward) / initialState (pop). Benefits: - No shared mutable state; no reset bookkeeping in nav/newStack/popUpTo. - No race between interleaved navigate calls. - Pop animations are automatically consistent with how the entry was entered - backing out of a bottom-bar tab now also fades. --- .../amethyst/ui/navigation/AppNavigation.kt | 162 +++++++++--------- .../ui/navigation/NavigationEffects.kt | 28 +-- .../amethyst/ui/navigation/navs/Nav.kt | 14 +- 3 files changed, 99 insertions(+), 105 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 38b20faf8..f1ec9ca6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -234,54 +234,54 @@ fun BuildNavigation( composable { VideoScreen(accountViewModel, nav) } composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } - composableFromEnd(nav) { PollsScreen(accountViewModel, nav) } - composableFromEnd(nav) { CommunitiesScreen(accountViewModel, nav) } - composableFromEnd(nav) { NewCommunityScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEnd(nav) { BadgesScreen(accountViewModel, nav) } - composableFromEnd(nav) { ProfileBadgesScreen(accountViewModel, nav) } + composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { CommunitiesScreen(accountViewModel, nav) } + composableFromEnd { NewCommunityScreen(accountViewModel, nav) } + composableFromEndArgs { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } - composableFromEnd(nav) { PicturesScreen(accountViewModel, nav) } - composableFromEnd(nav) { ProductsScreen(accountViewModel, nav) } - composableFromEnd(nav) { ShortsScreen(accountViewModel, nav) } - composableFromEnd(nav) { PublicChatsScreen(accountViewModel, nav) } - composableFromEnd(nav) { FollowPacksScreen(accountViewModel, nav) } - composableFromEnd(nav) { LiveStreamsScreen(accountViewModel, nav) } - composableFromEnd(nav) { AudioRoomsScreen(accountViewModel, nav) } - composableFromEnd(nav) { LongsScreen(accountViewModel, nav) } - composableFromEnd(nav) { ArticlesScreen(accountViewModel, nav) } - composableFromEnd(nav) { NewHlsVideoScreen(accountViewModel, nav) } + composableFromEnd { PicturesScreen(accountViewModel, nav) } + composableFromEnd { ProductsScreen(accountViewModel, nav) } + composableFromEnd { ShortsScreen(accountViewModel, nav) } + composableFromEnd { PublicChatsScreen(accountViewModel, nav) } + composableFromEnd { FollowPacksScreen(accountViewModel, nav) } + composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } + composableFromEnd { AudioRoomsScreen(accountViewModel, nav) } + composableFromEnd { LongsScreen(accountViewModel, nav) } + composableFromEnd { ArticlesScreen(accountViewModel, nav) } + composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } - composableFromEnd(nav) { WalletScreen(accountViewModel, nav) } - composableFromEnd(nav) { WalletSendScreen(accountViewModel, nav) } - composableFromEnd(nav) { WalletReceiveScreen(accountViewModel, nav) } - composableFromEnd(nav) { WalletTransactionsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { WalletDetailScreen(it.walletId, accountViewModel, nav) } - composableFromEnd(nav) { AddWalletScreen(accountViewModel, nav) } + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } + composableFromEnd { AddWalletScreen(accountViewModel, nav) } - composableFromEnd(nav) { ListOfPeopleListsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { PeopleListScreen(it.dTag, accountViewModel, nav) } - composableFromEndArgs(nav) { FollowPackScreen(it.dTag, accountViewModel, nav) } + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromEnd(nav) { ListOfBookmarkGroupsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromEnd(nav) { ListOfInterestSetsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { InterestSetScreen(it.dTag, accountViewModel, nav) } + composableFromEnd { ListOfInterestSetsScreen(accountViewModel, nav) } + composableFromEndArgs { InterestSetScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { InterestSetMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEnd(nav) { ListOfEmojiPacksScreen(accountViewModel, nav) } - composableFromEnd(nav) { MyEmojiListScreen(accountViewModel, nav) } - composableFromEnd(nav) { BrowseEmojiSetsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { EmojiPackScreen(it.dTag, accountViewModel, nav) } + composableFromEnd { ListOfEmojiPacksScreen(accountViewModel, nav) } + composableFromEnd { MyEmojiListScreen(accountViewModel, nav) } + composableFromEnd { BrowseEmojiSetsScreen(accountViewModel, nav) } + composableFromEndArgs { EmojiPackScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } @@ -292,68 +292,68 @@ fun BuildNavigation( composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } - composableFromEnd(nav) { AllSettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { AccountBackupScreen(accountViewModel, nav) } - composableFromEnd(nav) { SecurityFiltersScreen(accountViewModel, nav) } - composableFromEnd(nav) { PrivacyOptionsScreen(nav) } - composableFromEnd(nav) { NamecoinSettingsScreen(nav) } - composableFromEnd(nav) { OtsSettingsScreen(nav) } - composableFromEnd(nav) { BookmarkListScreen(accountViewModel, nav) } - composableFromEnd(nav) { OldBookmarkListScreen(accountViewModel, nav) } - composableFromEnd(nav) { PinnedNotesScreen(accountViewModel, nav) } - composableFromEnd(nav) { WebBookmarksScreen(accountViewModel, nav) } - composableFromEnd(nav) { DraftListScreen(accountViewModel, nav) } - composableFromEnd(nav) { SettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { UserSettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { ReactionsSettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { BottomBarSettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { CallSettingsScreen(accountViewModel, nav) } - composableFromEnd(nav) { ImportFollowListSelectUserScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { + composableFromEnd { AllSettingsScreen(accountViewModel, nav) } + composableFromEnd { AccountBackupScreen(accountViewModel, nav) } + composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { PrivacyOptionsScreen(nav) } + composableFromEnd { NamecoinSettingsScreen(nav) } + composableFromEnd { OtsSettingsScreen(nav) } + composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } + composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } + composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } + composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { SettingsScreen(accountViewModel, nav) } + composableFromEnd { UserSettingsScreen(accountViewModel, nav) } + composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } + composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } + composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) } - composableFromEndArgs(nav) { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs(nav) { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs(nav) { AllRelayListScreen(accountViewModel, nav) } - composableFromEnd(nav) { EventSyncScreen(accountViewModel, nav) } - composableFromEnd(nav) { RequestToVanishScreen(accountViewModel, nav) } - composableFromEnd(nav) { VanishEventsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { AllMediaServersScreen(accountViewModel, nav) } - composableFromEnd(nav) { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } - composableFromEnd(nav) { PaymentTargetsScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { UpdateReactionTypeScreen(accountViewModel, nav) } + composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEnd { EventSyncScreen(accountViewModel, nav) } + composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } + composableFromEnd { VanishEventsScreen(accountViewModel, nav) } + composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } + composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } + composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } - composableFromEndArgs(nav) { ProfileScreen(it.id, accountViewModel, nav) } - composableFromEndArgs(nav) { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs(nav) { HashtagScreen(it, accountViewModel, nav) } - composableFromEndArgs(nav) { GeoHashScreen(it, accountViewModel, nav) } - composableFromEndArgs(nav) { RelayFeedScreen(it, accountViewModel, nav) } - composableFromEndArgs(nav) { ChessGameScreen(it.gameId, accountViewModel, nav) } - composableFromEndArgs(nav) { RelayInformationScreen(it.url, accountViewModel, nav) } - composableFromEndArgs(nav) { RelayManagementScreen(it.url, accountViewModel, nav) } - composableFromEndArgs(nav) { RelayMembersScreen(it.url, accountViewModel, nav) } - composableFromEndArgs(nav) { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs(nav) { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs(nav) { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } - composableFromEndArgs(nav) { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } + composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - composableFromEnd(nav) { MarmotGroupListScreen(accountViewModel, nav) } - composableFromEndArgs(nav) { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } - composableFromEndArgs(nav) { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) } + composableFromEndArgs { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } composableFromBottom { CreateGroupScreen(accountViewModel, nav) } composableFromBottomArgs { AddMemberScreen(it.nostrGroupId, accountViewModel, nav) } composableFromBottomArgs { RemoveMemberScreen(it.nostrGroupId, accountViewModel, nav) } composableFromBottomArgs { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } - composableFromEndArgs(nav) { + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } - composableFromEndArgs(nav) { + composableFromEndArgs { LiveActivityChannelScreen( Address(it.kind, it.pubKeyHex, it.dTag), draftId = it.draftId, @@ -363,7 +363,7 @@ fun BuildNavigation( ) } - composableFromEndArgs(nav) { + composableFromEndArgs { EphemeralChatScreen( id = it.id, relayUrl = it.relayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index d81594717..7b113393d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -33,26 +33,26 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -inline fun NavGraphBuilder.composableFromEnd( - nav: Nav, - noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, -) { +// Per-entry hint stored on NavBackStackEntry.savedStateHandle by +// Nav.navBottomBar. When present, composableFromEnd skips the horizontal +// slide so bottom-bar taps fall back to the NavHost-level fade. +const val SKIP_SLIDE_ANIMATION_KEY = "skipSlideAnimation" + +fun NavBackStackEntry.skipsSlideAnimation(): Boolean = savedStateHandle.get(SKIP_SLIDE_ANIMATION_KEY) == true + +inline fun NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { composable( - enterTransition = { if (nav.skipSlideAnimation) null else slideInHorizontallyFromEnd }, - exitTransition = { if (nav.skipSlideAnimation) null else scaleOut }, - popEnterTransition = { if (nav.skipSlideAnimation) null else scaleIn }, - popExitTransition = { if (nav.skipSlideAnimation) null else slideOutHorizontallyToEnd }, + enterTransition = { if (targetState.skipsSlideAnimation()) null else slideInHorizontallyFromEnd }, + exitTransition = { if (targetState.skipsSlideAnimation()) null else scaleOut }, + popEnterTransition = { if (initialState.skipsSlideAnimation()) null else scaleIn }, + popExitTransition = { if (initialState.skipsSlideAnimation()) null else slideOutHorizontallyToEnd }, content = content, ) } -inline fun NavGraphBuilder.composableFromEndArgs( - nav: Nav, - noinline content: @Composable AnimatedContentScope.(T) -> Unit, -) { - composableFromEnd(nav) { +inline fun NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { + composableFromEnd { content(it.toRoute()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 6ace48aff..3267aef28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Stable import androidx.navigation.NavHostController +import com.vitorpamplona.amethyst.ui.navigation.SKIP_SLIDE_ANIMATION_KEY import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import kotlinx.coroutines.CoroutineScope @@ -38,11 +39,6 @@ class Nav( ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) - // Read by composableFromEnd transition lambdas; true means the next - // navigation is a bottom-bar tab swap and should skip the slide animation. - @Volatile - var skipSlideAnimation: Boolean = false - override fun closeDrawer() { navigationScope.launch { drawerState.close() } } @@ -54,7 +50,6 @@ class Nav( override fun nav(route: Route) { navigationScope.launch { if (getRouteWithArguments(route::class, controller) != route) { - skipSlideAnimation = false controller.navigate(route) } } @@ -64,7 +59,6 @@ class Nav( navigationScope.launch { val route = computeRoute() if (route != null && getRouteWithArguments(route::class, controller) != route) { - skipSlideAnimation = false controller.navigate(route) } } @@ -72,7 +66,6 @@ class Nav( override fun newStack(route: Route) { navigationScope.launch { - skipSlideAnimation = false controller.navigate(route) { popUpTo(route) { inclusive = true @@ -84,13 +77,15 @@ class Nav( override fun navBottomBar(route: Route) { navigationScope.launch { - skipSlideAnimation = true controller.navigate(route) { popUpTo(route) { inclusive = true } launchSingleTop = true } + // Stamp the entry so composableFromEnd's transition lambdas can + // skip the slide animation when this entry enters or exits. + controller.getBackStackEntry(route).savedStateHandle[SKIP_SLIDE_ANIMATION_KEY] = true } } @@ -106,7 +101,6 @@ class Nav( klass: KClass, ) { navigationScope.launch { - skipSlideAnimation = false controller.navigate(route) { popUpTo(klass) { inclusive = true } }