diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c816d405e..7f9fb1b30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,8 @@ dependencies { androidTestImplementation(project(":core-datastore-test")) androidTestImplementation(project(":core-data-test")) androidTestImplementation(project(":core-network")) + androidTestImplementation(libs.androidx.navigation.testing) + debugImplementation(libs.androidx.compose.ui.testManifest) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index b68211eb1..90d5d92dc 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -46,19 +46,19 @@ class NavigationTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) - /** - * Use the primary activity to initialize the app normally. - */ - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - /** * Create a temporary folder used to create a Data Store file. This guarantees that * the file is removed in between each test, preventing a crash. */ - @BindValue @get:Rule(order = 2) + @BindValue @get:Rule(order = 1) val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + /** + * Use the primary activity to initialize the app normally. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + // The strings used for matching in these tests private lateinit var done: String private lateinit var navigateUp: String diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt new file mode 100644 index 000000000..2814fdbc9 --- /dev/null +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2022 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.ui + +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.composable +import androidx.navigation.createGraph +import androidx.navigation.testing.TestNavHostController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Tests [NiaAppState]. + * + * Note: This could become an unit test if Robolectric is added to the project and the Context + * is faked. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +class NiaAppStateTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var state: NiaAppState + + @Test + fun niaAppState_currentDestination() { + var currentDestination: String? = null + + composeTestRule.setContent { + val navController = rememberTestNavController() + state = remember(navController) { + NiaAppState( + windowSizeClass = getCompactWindowClass(), + navController = navController + ) + } + + // Update currentDestination whenever it changes + currentDestination = state.currentDestination?.route + + // Navigate to destination b once + LaunchedEffect(Unit) { + navController.setCurrentDestination("b") + } + } + + assertEquals("b", currentDestination) + } + + @Test + fun niaAppState_destinations() { + composeTestRule.setContent { + state = rememberNiaAppState(getCompactWindowClass()) + } + + assertEquals(3, state.topLevelDestinations.size) + assertTrue(state.topLevelDestinations[0].destination.contains("for_you")) + assertTrue(state.topLevelDestinations[1].destination.contains("bookmarks")) + assertTrue(state.topLevelDestinations[2].destination.contains("interests")) + } + + @Test + fun niaAppState_showBottomBar_compact() { + composeTestRule.setContent { + state = NiaAppState( + windowSizeClass = getCompactWindowClass(), + navController = NavHostController(LocalContext.current) + ) + } + + assertTrue(state.shouldShowBottomBar) + assertFalse(state.shouldShowNavRail) + } + + @Test + fun niaAppState_showNavRail_medium() { + composeTestRule.setContent { + state = NiaAppState( + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), + navController = NavHostController(LocalContext.current) + ) + } + + assertTrue(state.shouldShowNavRail) + assertFalse(state.shouldShowBottomBar) + } + + @Test + fun niaAppState_showNavRail_large() { + composeTestRule.setContent { + state = NiaAppState( + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + navController = NavHostController(LocalContext.current) + ) + } + + assertTrue(state.shouldShowNavRail) + assertFalse(state.shouldShowBottomBar) + } + + private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp)) +} + +@Composable +private fun rememberTestNavController(): TestNavHostController { + val context = LocalContext.current + val navController = remember { + TestNavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = "a") { + composable("a") { } + composable("b") { } + composable("c") { } + } + } + } + return navController +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d452a83e..f21692f5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ Unit, + onBackClick: () -> Unit, windowSizeClass: WindowSizeClass, modifier: Modifier = Modifier, - navController: NavHostController = rememberNavController(), startDestination: String = ForYouDestination.route ) { NavHost( @@ -55,11 +57,19 @@ fun NiaNavHost( ) bookmarksGraph(windowSizeClass) interestsGraph( - navigateToTopic = { navController.navigate("${TopicDestination.route}/$it") }, - navigateToAuthor = { navController.navigate("${AuthorDestination.route}/$it") }, + navigateToTopic = { + onNavigateToDestination( + TopicDestination, TopicDestination.createNavigationRoute(it) + ) + }, + navigateToAuthor = { + onNavigateToDestination( + AuthorDestination, AuthorDestination.createNavigationRoute(it) + ) + }, nestedGraphs = { - topicGraph(onBackClick = { navController.popBackStack() }) - authorGraph(onBackClick = { navController.popBackStack() }) + topicGraph(onBackClick) + authorGraph(onBackClick) } ) } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt deleted file mode 100644 index 633682bc1..000000000 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2022 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.navigation - -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController -import androidx.tracing.trace -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons -import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination -import com.google.samples.apps.nowinandroid.feature.foryou.R.string.for_you -import com.google.samples.apps.nowinandroid.feature.foryou.R.string.saved -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination -import com.google.samples.apps.nowinandroid.feature.interests.R.string.interests -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsDestination - -/** - * Routes for the different top level destinations in the application. Each of these destinations - * can contain one or more screens (based on the window size). Navigation from one screen to the - * next within a single destination will be handled directly in composables. - */ - -/** - * Models the navigation top level actions in the app. - */ -class NiaTopLevelNavigation(private val navController: NavHostController) { - - fun navigateTo(destination: TopLevelDestination) { - trace("Navigation: $destination") { - 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 - } - } - } -} - -data class TopLevelDestination( - val route: String, - val selectedIcon: Icon, - val unselectedIcon: Icon, - val iconTextId: Int -) - -val TOP_LEVEL_DESTINATIONS = listOf( - TopLevelDestination( - route = ForYouDestination.route, - selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), - unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), - iconTextId = for_you - ), - TopLevelDestination( - route = BookmarksDestination.route, - selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), - unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), - iconTextId = saved - ), - TopLevelDestination( - route = InterestsDestination.route, - selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), - unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), - iconTextId = interests - ) -) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt new file mode 100644 index 000000000..4a2523bb5 --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2022 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.navigation + +import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination + +/** + * Type for the top level destinations in the application. Each of these destinations + * can contain one or more screens (based on the window size). Navigation from one screen to the + * next within a single destination will be handled directly in composables. + */ +data class TopLevelDestination( + override val route: String, + override val destination: String, + val selectedIcon: Icon, + val unselectedIcon: Icon, + val iconTextId: Int +) : NiaNavigationDestination diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 28f02a410..29fc62b67 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -33,12 +33,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass 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.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -46,11 +42,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem @@ -59,10 +52,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect import com.google.samples.apps.nowinandroid.navigation.NiaNavHost -import com.google.samples.apps.nowinandroid.navigation.NiaTopLevelNavigation -import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @OptIn( @@ -71,16 +61,11 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination ExperimentalComposeUiApi::class ) @Composable -fun NiaApp(windowSizeClass: WindowSizeClass) { +fun NiaApp( + windowSizeClass: WindowSizeClass, + appState: NiaAppState = rememberNiaAppState(windowSizeClass) +) { NiaTheme { - val navController = rememberNavController() - val niaTopLevelNavigation = remember(navController) { - NiaTopLevelNavigation(navController) - } - - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - NiaBackground { Scaffold( modifier = Modifier.semantics { @@ -89,12 +74,11 @@ fun NiaApp(windowSizeClass: WindowSizeClass) { containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, bottomBar = { - if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || - windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact - ) { + if (appState.shouldShowBottomBar) { NiaBottomBar( - onNavigateToTopLevelDestination = niaTopLevelNavigation::navigateTo, - currentDestination = currentDestination + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigate, + currentDestination = appState.currentDestination ) } } @@ -108,19 +92,20 @@ fun NiaApp(windowSizeClass: WindowSizeClass) { ) ) ) { - if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact && - windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact - ) { + if (appState.shouldShowNavRail) { NiaNavRail( - onNavigateToTopLevelDestination = niaTopLevelNavigation::navigateTo, - currentDestination = currentDestination, + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigate, + currentDestination = appState.currentDestination, modifier = Modifier.safeDrawingPadding() ) } NiaNavHost( - windowSizeClass = windowSizeClass, - navController = navController, + navController = appState.navController, + onBackClick = appState::onBackClick, + onNavigateToDestination = appState::navigate, + windowSizeClass = appState.windowSizeClass, modifier = Modifier .padding(padding) .consumedWindowInsets(padding) @@ -128,33 +113,23 @@ fun NiaApp(windowSizeClass: WindowSizeClass) { } } } - JankMetricDisposableEffect(navController) { metricsHolder -> - val listener = NavController.OnDestinationChangedListener { _, destination, _ -> - metricsHolder.state?.addState("Navigation", destination.route.toString()) - } - - navController.addOnDestinationChangedListener(listener) - - onDispose { - navController.removeOnDestinationChangedListener(listener) - } - } } } @Composable private fun NiaNavRail( - onNavigateToTopLevelDestination: (TopLevelDestination) -> Unit, + destinations: List, + onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination?, modifier: Modifier = Modifier, ) { NiaNavigationRail(modifier = modifier) { - TOP_LEVEL_DESTINATIONS.forEach { destination -> + destinations.forEach { destination -> val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true NiaNavigationRailItem( selected = selected, - onClick = { onNavigateToTopLevelDestination(destination) }, + onClick = { onNavigateToDestination(destination) }, icon = { val icon = if (selected) { destination.selectedIcon @@ -180,7 +155,8 @@ private fun NiaNavRail( @Composable private fun NiaBottomBar( - onNavigateToTopLevelDestination: (TopLevelDestination) -> Unit, + destinations: List, + onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination? ) { // Wrap the navigation bar in a surface so the color behind the system @@ -193,13 +169,12 @@ private fun NiaBottomBar( ) ) ) { - - TOP_LEVEL_DESTINATIONS.forEach { destination -> + destinations.forEach { destination -> val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true NiaNavigationBarItem( selected = selected, - onClick = { onNavigateToTopLevelDestination(destination) }, + onClick = { onNavigateToDestination(destination) }, icon = { val icon = if (selected) { destination.selectedIcon diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt new file mode 100644 index 000000000..dc620490a --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2022 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.ui + +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.tracing.trace +import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon +import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon +import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination +import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect +import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR +import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksDestination +import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR +import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination +import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR +import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsDestination +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination + +@Composable +fun rememberNiaAppState( + windowSizeClass: WindowSizeClass, + navController: NavHostController = rememberNavController() +): NiaAppState { + NavigationTrackingSideEffect(navController) + return remember(navController, windowSizeClass) { + NiaAppState(navController, windowSizeClass) + } +} + +@Stable +class NiaAppState( + val navController: NavHostController, + val windowSizeClass: WindowSizeClass +) { + val currentDestination: NavDestination? + @Composable get() = navController + .currentBackStackEntryAsState().value?.destination + + val shouldShowBottomBar: Boolean + get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || + windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact + + val shouldShowNavRail: Boolean + get() = !shouldShowBottomBar + + /** + * Top level destinations to be used in the BottomBar and NavRail + */ + val topLevelDestinations: List = listOf( + TopLevelDestination( + route = ForYouDestination.route, + destination = ForYouDestination.destination, + selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), + unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), + iconTextId = forYouR.string.for_you + ), + TopLevelDestination( + route = BookmarksDestination.route, + destination = BookmarksDestination.destination, + selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), + unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), + iconTextId = bookmarksR.string.saved + ), + TopLevelDestination( + route = InterestsDestination.route, + destination = InterestsDestination.destination, + selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), + unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), + iconTextId = interestsR.string.interests + ) + ) + + /** + * UI logic for navigating to a particular destination in the app. The NavigationOptions to + * navigate with are based on the type of destination, which could be a top level destination or + * just a regular destination. + * + * Top level destinations have only one copy of the destination of the back stack, and save and + * restore state whenever you navigate to and from it. + * Regular destinations can have multiple copies in the back stack and state isn't saved nor + * restored. + * + * @param destination: The [NiaNavigationDestination] the app needs to navigate to. + * @param route: Optional route to navigate to in case the destination contains arguments. + */ + fun navigate(destination: NiaNavigationDestination, route: String? = null) { + trace("Navigation: $destination") { + if (destination is TopLevelDestination) { + navController.navigate(route ?: 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 + } + } else { + navController.navigate(route ?: destination.route) + } + } + } + + fun onBackClick() { + navController.popBackStack() + } +} + +/** + * Stores information about navigation events to be used with JankStats + */ +@Composable +private fun NavigationTrackingSideEffect(navController: NavHostController) { + JankMetricDisposableEffect(navController) { metricsHolder -> + val listener = NavController.OnDestinationChangedListener { _, destination, _ -> + metricsHolder.state?.addState("Navigation", destination.route.toString()) + } + + navController.addOnDestinationChangedListener(listener) + + onDispose { + navController.removeOnDestinationChangedListener(listener) + } + } +} diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/navigation/AuthorNavigation.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/navigation/AuthorNavigation.kt index 983ef69cd..ee37bab95 100644 --- a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/navigation/AuthorNavigation.kt +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/navigation/AuthorNavigation.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.feature.author.navigation +import android.net.Uri +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -24,20 +26,34 @@ import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestina import com.google.samples.apps.nowinandroid.feature.author.AuthorRoute object AuthorDestination : NiaNavigationDestination { - override val route = "author_route" - override val destination = "author_destination" const val authorIdArg = "authorId" + override val route = "author_route/{$authorIdArg}" + override val destination = "author_destination" + + /** + * Creates destination route for an authorId that could include special characters + */ + fun createNavigationRoute(authorIdArg: String): String { + val encodedId = Uri.encode(authorIdArg) + return "author_route/$encodedId" + } + + /** + * Returns the authorId from a [NavBackStackEntry] after an author destination navigation call + */ + fun fromNavArgs(entry: NavBackStackEntry): String { + val encodedId = entry.arguments?.getString(authorIdArg)!! + return Uri.decode(encodedId) + } } fun NavGraphBuilder.authorGraph( onBackClick: () -> Unit ) { composable( - route = "${AuthorDestination.route}/{${AuthorDestination.authorIdArg}}", + route = AuthorDestination.route, arguments = listOf( - navArgument(AuthorDestination.authorIdArg) { - type = NavType.StringType - } + navArgument(AuthorDestination.authorIdArg) { type = NavType.StringType } ) ) { AuthorRoute(onBackClick = onBackClick) diff --git a/feature-bookmarks/src/main/res/values/strings.xml b/feature-bookmarks/src/main/res/values/strings.xml index 10f83cae0..bf59e2ec8 100644 --- a/feature-bookmarks/src/main/res/values/strings.xml +++ b/feature-bookmarks/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ limitations under the License. --> + Saved Loading saved… Saved Search diff --git a/feature-foryou/src/main/res/values/strings.xml b/feature-foryou/src/main/res/values/strings.xml index fb8b02307..7ed3683d9 100644 --- a/feature-foryou/src/main/res/values/strings.xml +++ b/feature-foryou/src/main/res/values/strings.xml @@ -17,7 +17,6 @@ For you Episodes - Saved Done Loading for you… Navigate up diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index 39d3b0875..f3d4d021b 100644 --- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.feature.topic.navigation +import android.net.Uri +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -24,20 +26,34 @@ import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestina import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute object TopicDestination : NiaNavigationDestination { - override val route = "topic_route" - override val destination = "topic_destination" const val topicIdArg = "topicId" + override val route = "topic_route/{$topicIdArg}" + override val destination = "topic_destination" + + /** + * Creates destination route for a topicId that could include special characters + */ + fun createNavigationRoute(topicIdArg: String): String { + val encodedId = Uri.encode(topicIdArg) + return "topic_route/$encodedId" + } + + /** + * Returns the topicId from a [NavBackStackEntry] after a topic destination navigation call + */ + fun fromNavArgs(entry: NavBackStackEntry): String { + val encodedId = entry.arguments?.getString(topicIdArg)!! + return Uri.decode(encodedId) + } } fun NavGraphBuilder.topicGraph( onBackClick: () -> Unit ) { composable( - route = "${TopicDestination.route}/{${TopicDestination.topicIdArg}}", + route = TopicDestination.route, arguments = listOf( - navArgument(TopicDestination.topicIdArg) { - type = NavType.StringType - } + navArgument(TopicDestination.topicIdArg) { type = NavType.StringType } ) ) { TopicRoute(onBackClick = onBackClick) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d949136c..a58115498 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidDesugarJdkLibs = "1.1.5" androidGradlePlugin = "7.2.1" androidxActivity = "1.4.0" androidxAppCompat = "1.4.2" -androidxCompose = "1.2.0-rc02" +androidxCompose = "1.2.0-rc03" androidxComposeCompiler = "1.2.0" androidxComposeMaterial3 = "1.0.0-alpha13" androidxCore = "1.8.0" @@ -76,6 +76,7 @@ androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "life androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-savedstate-ktx = { group = "androidx.savedstate", name = "savedstate-ktx", version.ref= "androidxSavedState"} androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" }