Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Reply/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)

implementation(libs.androidx.activity.compose)
implementation(libs.androidx.window)
Expand Down
113 changes: 49 additions & 64 deletions Reply/app/src/main/java/com/example/reply/ui/ReplyApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.window.layout.DisplayFeature
import androidx.window.layout.FoldingFeature
import com.example.reply.ui.navigation.ReplyNavigationActions
import com.example.reply.ui.navigation.ReplyNavigationWrapper
import com.example.reply.ui.navigation.ReplyTopLevelDestination
import com.example.reply.ui.navigation.Route
import com.example.reply.ui.utils.DevicePosture
import com.example.reply.ui.utils.ReplyContentType
Expand Down Expand Up @@ -84,68 +82,55 @@ fun ReplyApp(
else -> ReplyContentType.SINGLE_PANE
}

val navController = rememberNavController()
val navigationActions = remember(navController) {
ReplyNavigationActions(navController)
val backStack = remember { mutableStateListOf<Route>(Route.Inbox) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The backStack is created and managed locally within ReplyApp, but the functions for navigating to and from detail screens (navigateToDetail, closeDetailScreen) are passed in from the caller. This prevents the caller from modifying the backStack to add or remove detail screen routes, which appears to be a regression from the previous navigation implementation.

To fix this, the backStack state should be hoisted. ReplyApp should receive the backStack as a parameter, allowing the caller to create, own, and modify it. This will re-enable navigation to detail screens.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detail screen navigation in Reply is entirely ViewModel-driven — `navigateToDetail` calls `viewModel.setOpenedEmail()` and `closeDetailScreen` calls `viewModel.closeDetailScreen()`, both of which mutate `ReplyHomeUIState` state. The detail view is rendered inline within `ReplyInboxScreen` based on `replyHomeUIState.isDetailOnlyOpen`, not as a separate navigation destination. This was also the case with Nav2 — the `NavHost` only had four top-level `composable<Route.*>` entries and no detail route.

So there's no regression here — the callers never modified the navigation backstack for detail screens, they only modify ViewModel state.

val currentRoute: Route? = backStack.lastOrNull()

val navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit = { destination ->
if (backStack.lastOrNull() != destination.route) {
if (backStack.size > 1) backStack.subList(1, backStack.size).clear()
if (destination.route != Route.Inbox) {
backStack.add(destination.route)
}
}
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination

Surface {
ReplyNavigationWrapper(
currentDestination = currentDestination,
navigateToTopLevelDestination = navigationActions::navigateTo,
currentRoute = currentRoute,
navigateToTopLevelDestination = navigateToTopLevelDestination,
) {
ReplyNavHost(
navController = navController,
contentType = contentType,
displayFeatures = displayFeatures,
replyHomeUIState = replyHomeUIState,
navigationType = navSuiteType.toReplyNavType(),
closeDetailScreen = closeDetailScreen,
navigateToDetail = navigateToDetail,
toggleSelectedEmail = toggleSelectedEmail,
)
}
}
}

@Composable
private fun ReplyNavHost(
navController: NavHostController,
contentType: ReplyContentType,
displayFeatures: List<DisplayFeature>,
replyHomeUIState: ReplyHomeUIState,
navigationType: ReplyNavigationType,
closeDetailScreen: () -> Unit,
navigateToDetail: (Long, ReplyContentType) -> Unit,
toggleSelectedEmail: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = Route.Inbox,
) {
composable<Route.Inbox> {
ReplyInboxScreen(
contentType = contentType,
replyHomeUIState = replyHomeUIState,
navigationType = navigationType,
displayFeatures = displayFeatures,
closeDetailScreen = closeDetailScreen,
navigateToDetail = navigateToDetail,
toggleSelectedEmail = toggleSelectedEmail,
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { route ->
when (route) {
Route.Inbox -> NavEntry(route) {
ReplyInboxScreen(
contentType = contentType,
replyHomeUIState = replyHomeUIState,
navigationType = navSuiteType.toReplyNavType(),
displayFeatures = displayFeatures,
closeDetailScreen = closeDetailScreen,
navigateToDetail = navigateToDetail,
toggleSelectedEmail = toggleSelectedEmail,
)
}
Route.DirectMessages -> NavEntry(route) {
EmptyComingSoon()
}
Route.Articles -> NavEntry(route) {
EmptyComingSoon()
}
Route.Groups -> NavEntry(route) {
EmptyComingSoon()
}
}
},
)
}
composable<Route.DirectMessages> {
EmptyComingSoon()
}
composable<Route.Articles> {
EmptyComingSoon()
}
composable<Route.Groups> {
EmptyComingSoon()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@

package com.example.reply.ui.navigation

import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation3.runtime.NavKey
import com.example.reply.R
import kotlinx.serialization.Serializable

sealed interface Route {
sealed interface Route : NavKey {
@Serializable data object Inbox : Route
@Serializable data object Articles : Route
@Serializable data object DirectMessages : Route
Expand All @@ -30,25 +29,6 @@ sealed interface Route {

data class ReplyTopLevelDestination(val route: Route, val selectedIcon: Int, val unselectedIcon: Int, val iconTextId: Int)

class ReplyNavigationActions(private val navController: NavHostController) {

fun navigateTo(destination: ReplyTopLevelDestination) {
navController.navigate(destination.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
}

val TOP_LEVEL_DESTINATIONS = listOf(
ReplyTopLevelDestination(
route = Route.Inbox,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.unit.toSize
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
Expand All @@ -83,7 +81,7 @@ class ReplyNavSuiteScope(val navSuiteType: NavigationSuiteType)

@Composable
fun ReplyNavigationWrapper(
currentDestination: NavDestination?,
currentRoute: Route?,
navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit,
content: @Composable ReplyNavSuiteScope.() -> Unit,
) {
Expand Down Expand Up @@ -125,7 +123,7 @@ fun ReplyNavigationWrapper(
gesturesEnabled = gesturesEnabled,
drawerContent = {
ModalNavigationDrawerContent(
currentDestination = currentDestination,
currentRoute = currentRoute,
navigationContentPosition = navContentPosition,
navigateToTopLevelDestination = navigateToTopLevelDestination,
onDrawerClicked = {
Expand All @@ -141,11 +139,11 @@ fun ReplyNavigationWrapper(
navigationSuite = {
when (navLayoutType) {
NavigationSuiteType.NavigationBar -> ReplyBottomNavigationBar(
currentDestination = currentDestination,
currentRoute = currentRoute,
navigateToTopLevelDestination = navigateToTopLevelDestination,
)
NavigationSuiteType.NavigationRail -> ReplyNavigationRail(
currentDestination = currentDestination,
currentRoute = currentRoute,
navigationContentPosition = navContentPosition,
navigateToTopLevelDestination = navigateToTopLevelDestination,
onDrawerClicked = {
Expand All @@ -155,7 +153,7 @@ fun ReplyNavigationWrapper(
},
)
NavigationSuiteType.NavigationDrawer -> PermanentNavigationDrawerContent(
currentDestination = currentDestination,
currentRoute = currentRoute,
navigationContentPosition = navContentPosition,
navigateToTopLevelDestination = navigateToTopLevelDestination,
)
Expand All @@ -169,7 +167,7 @@ fun ReplyNavigationWrapper(

@Composable
fun ReplyNavigationRail(
currentDestination: NavDestination?,
currentRoute: Route?,
navigationContentPosition: ReplyNavigationContentPosition,
navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit,
onDrawerClicked: () -> Unit = {},
Expand Down Expand Up @@ -216,7 +214,7 @@ fun ReplyNavigationRail(
) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationRailItem(
selected = currentDestination.hasRoute(replyDestination),
selected = currentRoute == replyDestination.route,
onClick = { navigateToTopLevelDestination(replyDestination) },
icon = {
Icon(
Expand All @@ -233,11 +231,11 @@ fun ReplyNavigationRail(
}

@Composable
fun ReplyBottomNavigationBar(currentDestination: NavDestination?, navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit) {
fun ReplyBottomNavigationBar(currentRoute: Route?, navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationBarItem(
selected = currentDestination.hasRoute(replyDestination),
selected = currentRoute == replyDestination.route,
onClick = { navigateToTopLevelDestination(replyDestination) },
icon = {
Icon(
Expand All @@ -252,7 +250,7 @@ fun ReplyBottomNavigationBar(currentDestination: NavDestination?, navigateToTopL

@Composable
fun PermanentNavigationDrawerContent(
currentDestination: NavDestination?,
currentRoute: Route?,
navigationContentPosition: ReplyNavigationContentPosition,
navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit,
) {
Expand Down Expand Up @@ -307,7 +305,7 @@ fun PermanentNavigationDrawerContent(
) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationDrawerItem(
selected = currentDestination.hasRoute(replyDestination),
selected = currentRoute == replyDestination.route,
label = {
Text(
text = stringResource(id = replyDestination.iconTextId),
Expand Down Expand Up @@ -337,7 +335,7 @@ fun PermanentNavigationDrawerContent(

@Composable
fun ModalNavigationDrawerContent(
currentDestination: NavDestination?,
currentRoute: Route?,
navigationContentPosition: ReplyNavigationContentPosition,
navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit,
onDrawerClicked: () -> Unit = {},
Expand Down Expand Up @@ -403,7 +401,7 @@ fun ModalNavigationDrawerContent(
) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationDrawerItem(
selected = currentDestination.hasRoute(replyDestination),
selected = currentRoute == replyDestination.route,
label = {
Text(
text = stringResource(id = replyDestination.iconTextId),
Expand Down Expand Up @@ -472,5 +470,3 @@ enum class LayoutType {
HEADER,
CONTENT,
}

fun NavDestination?.hasRoute(destination: ReplyTopLevelDestination): Boolean = this?.hasRoute(destination.route::class) ?: false
5 changes: 5 additions & 0 deletions Reply/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ androidx-lifecycle = "2.8.2"
androidx-lifecycle-compose = "2.10.0"
androidx-lifecycle-runtime-compose = "2.10.0"
androidx-navigation = "2.9.6"
androidx-navigation3 = "1.0.0"
androidx-lifecycle-viewmodel-navigation3 = "2.10.0"
androidx-palette = "1.0.0"
androidx-test = "1.7.0"
androidx-test-espresso = "3.7.0"
Expand Down Expand Up @@ -106,6 +108,9 @@ androidx-material-icons-core = { module = "androidx.compose.material:material-ic
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx-navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-viewmodel-navigation3" }
androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
Expand Down