Mirroring Nav2 back stack state

dt/nav3-c
Don Turner 2 months ago
parent f024e283f9
commit 995e1ab13d

@ -12,6 +12,7 @@
</option> </option>
<option name="IMPORT_LAYOUT_TABLE"> <option name="IMPORT_LAYOUT_TABLE">
<value> <value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="" withSubpackages="true" static="false" /> <package name="" withSubpackages="true" static="false" />
<emptyLine /> <emptyLine />
<package name="javax" withSubpackages="true" static="false" /> <package name="javax" withSubpackages="true" static="false" />

@ -72,6 +72,7 @@ android {
dependencies { dependencies {
implementation(projects.feature.interests) implementation(projects.feature.interests)
implementation(projects.feature.foryou) implementation(projects.feature.foryou)
implementation(projects.feature.bookmarks.api)
implementation(projects.feature.bookmarks.impl) implementation(projects.feature.bookmarks.impl)
implementation(projects.feature.topic) implementation(projects.feature.topic)
implementation(projects.feature.search) implementation(projects.feature.search)

@ -19,11 +19,11 @@ package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entry
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
@ -34,7 +34,6 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
import kotlinx.serialization.Serializable
/** /**
* Top-level navigation graph. Navigation is organized as explained at * Top-level navigation graph. Navigation is organized as explained at
@ -50,38 +49,41 @@ fun NiaNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val navController = appState.navController val navController = appState.navController
val backStack = rememberNavBackStack(LegacyRoute) NavDisplay(
backStack = listOf(Unit),
NavDisplay(backStack = backStack, entryProvider = entryProvider { onBack = { },
entry<LegacyRoute> { entryProvider = entryProvider(
NavHost( fallback = { key ->
navController = navController, NavEntry(key = key) {
startDestination = ForYouBaseRoute, NavHost(
modifier = modifier, navController = navController,
) { startDestination = ForYouBaseRoute,
forYouSection( modifier = modifier,
onTopicClick = navController::navigateToTopic, ) {
) { forYouSection(
topicScreen( onTopicClick = navController::navigateToTopic,
showBackButton = true, ) {
onBackClick = navController::popBackStack, topicScreen(
onTopicClick = navController::navigateToTopic, showBackButton = true,
) onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
} }
bookmarksScreen( },
onTopicClick = navController::navigateToInterests, ) {
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}
})
}
@Serializable },
data object LegacyRoute : NavKey )
}

@ -136,6 +136,9 @@ internal fun NiaApp(
) { ) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
// TODO: This is only used to determine whether the bottom nav item is selected
// this can be improved by just getting
val currentDestination = appState.currentDestination val currentDestination = appState.currentDestination
if (showSettingsDialog) { if (showSettingsDialog) {
@ -144,6 +147,10 @@ internal fun NiaApp(
) )
} }
// TODO: figure out how have the existing navigation bar support both old and new routes
// essentially we need a migration path from what's below to the common UI recipe
// this probably comes down to how the back stack is modelled.
NiaNavigationSuiteScaffold( NiaNavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination -> appState.topLevelDestinations.forEach { destination ->
@ -167,9 +174,9 @@ internal fun NiaApp(
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = modifier =
Modifier Modifier
.testTag("NiaNavItem") .testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier), .then(if (hasUnread) Modifier.notificationDot() else Modifier),
) )
} }
}, },

@ -16,9 +16,11 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -43,11 +45,13 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKM
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@Composable @Composable
@ -59,6 +63,11 @@ fun rememberNiaAppState(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(navController)
val nav3Navigator = remember(navController) {
Nav3NavigatorSimple(navController)
}
return remember( return remember(
navController, navController,
coroutineScope, coroutineScope,
@ -196,3 +205,136 @@ private fun NavigationTrackingSideEffect(navController: NavHostController) {
} }
} }
} }
class Nav3NavigatorSimple(val navController: NavHostController){
val backStack = mutableStateListOf<Any>()
val coroutineScope = CoroutineScope(Job())
init {
coroutineScope.launch {
navController.currentBackStack.collect { nav2BackStack ->
println("Nav2 back stack changed")
backStack.clear()
for (nav2Entry in nav2BackStack){
println("Adding destination: ${nav2Entry.destination}")
backStack.add(nav2Entry.destination)
}
}
}
}
}
/*
class Nav3Navigator<T: Any>(val navController: NavHostController, startRoute: T) {
init {
coroutineScope.launch {
navController.currentBackStack.collect { nav2BackStack ->
println("Nav2 back stack changed")
// TODO: Convert this into a nav3 back stack
for (nav2Entry in nav2BackStack){
println("Destination: ${nav2Entry.destination}")
}
}
}
navController.addOnDestinationChangedListener(
listener = object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?,
) {
println("NavController destination changed to $destination")
// TODO: something! Or maybe we just listen to the back stack and mirror it here
}
}
)
}
// Keep track of the baseRoute - this is the route that is always at the bottom of the stack
private val baseRoute = startRoute
// Maintain a stack for each top level route
private var topLevelStacks : LinkedHashMap<T, SnapshotStateList<T>> = linkedMapOf(
baseRoute to mutableStateListOf(baseRoute)
)
// Expose the current top level route for consumers
var topLevelRoute by mutableStateOf(baseRoute)
private set
// Expose the back stack so it can be rendered by the NavDisplay
val backStack : SnapshotStateList<T> = mutableStateListOf(baseRoute)
private fun updateBackStack() {
backStack.apply {
// TODO: Could this be optimised?
clear()
addAll(topLevelStacks.flatMap { it.value })
}
println("Top level stacks: $topLevelStacks")
println("Backstack state: $backStack")
}
fun goTo(route: T, navOptions: NavOptions? = null){
backStack.add(route)
navController.navigate(route, navOptions)
*//*if (route is NavKey){
if (route is TopLevelRoute){
// Pop everything up to the base route stack
for (existingKey in topLevelStacks.keys.reversed()){
if (existingKey != baseRoute) topLevelStacks.remove(existingKey)
}
if (route != baseRoute) {
topLevelStacks.put(route, mutableStateListOf(route))
}
topLevelRoute = route
} else {
topLevelStacks[topLevelRoute]?.add(route)
}
topLevelStacks[topLevelRoute]?.add(route)
updateBackStack()
} else {
navController.navigate(route, navOptions)
}*//*
}
*//*
fun removeLast(){
val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull()
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
topLevelRoute = topLevelStacks.keys.last()
updateBackStack()
}
*//*
fun goBack(){
val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull()
// If the removed key was a top level key, remove the associated top level stack
if (removedKey is TopLevelRoute){
topLevelStacks.remove(removedKey)
topLevelRoute = topLevelStacks.keys.last()
}
updateBackStack()
if (removedKey is LegacyRoute){
navController.popBackStack()
}
}
}*/

@ -0,0 +1,20 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.navigation
// Marker interface for top level routes
// interface TopLevelRoute

@ -24,5 +24,6 @@ android {
} }
dependencies { dependencies {
implementation(projects.core.navigation)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }

@ -0,0 +1,21 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

@ -26,7 +26,7 @@ android {
dependencies { dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.navigation)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)

@ -101,7 +101,7 @@ internal fun BookmarksRoute(
*/ */
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable @Composable
internal fun BookmarksScreen( fun BookmarksScreen(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,

Loading…
Cancel
Save