From af7e2517f23d780900b83b5c6bcec2903f4074c7 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Tue, 19 Jul 2022 14:07:59 +0200 Subject: [PATCH 1/3] Add state holder for NiaApp composable --- app/build.gradle.kts | 2 + .../apps/nowinandroid/ui/NiaAppStateTest.kt | 145 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 2 +- .../{NiaApp.kt => NiaApplication.kt} | 2 +- .../nowinandroid/navigation/NiaNavHost.kt | 22 ++- .../navigation/NiaTopLevelNavigation.kt | 81 ---------- .../navigation/TopLevelDestination.kt | 33 ++++ .../samples/apps/nowinandroid/ui/NiaApp.kt | 73 +++------ .../apps/nowinandroid/ui/NiaAppState.kt | 129 ++++++++++++++++ .../author/navigation/AuthorNavigation.kt | 19 ++- .../topic/navigation/TopicNavigation.kt | 19 ++- gradle/libs.versions.toml | 1 + 12 files changed, 378 insertions(+), 150 deletions(-) create mode 100644 app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt rename app/src/main/java/com/google/samples/apps/nowinandroid/{NiaApp.kt => NiaApplication.kt} (96%) delete mode 100644 app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt create mode 100644 app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt create mode 100644 app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0dc999b2..22239cc8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,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/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt new file mode 100644 index 000000000..597c17bcf --- /dev/null +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -0,0 +1,145 @@ +/* + * 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(2, state.topLevelDestinations.size) + assertTrue(state.topLevelDestinations[0].destination.contains("for_you")) + assertTrue(state.topLevelDestinations[1].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( @@ -53,11 +55,19 @@ fun NiaNavHost( windowSizeClass = 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 54d7bd402..000000000 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt +++ /dev/null @@ -1,81 +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.foryou.R.string.for_you -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 = 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..323c62dae --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -0,0 +1,129 @@ +/* + * 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.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.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) + } +} + +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 + + 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 = InterestsDestination.route, + destination = InterestsDestination.destination, + selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), + unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), + iconTextId = interestsR.string.interests + ) + ) + + // -------------------- + // NAVIGATION LOGIC + // -------------------- + + 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() + } +} + +@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..d3749335e 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,7 @@ package com.google.samples.apps.nowinandroid.feature.author.navigation +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -24,20 +25,26 @@ 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" + + fun createNavigationRoute(authorIdArg: String): String { + return "author_route/$authorIdArg" + } + + fun fromNavArgs(entry: NavBackStackEntry): String { + return entry.arguments?.getString(authorIdArg)!! + } } 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-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..2368e713b 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,7 @@ package com.google.samples.apps.nowinandroid.feature.topic.navigation +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -24,20 +25,26 @@ 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" + + fun createNavigationRoute(topicIdArg: String): String { + return "topic_route/$topicIdArg" + } + + fun fromNavArgs(entry: NavBackStackEntry): String { + return entry.arguments?.getString(topicIdArg)!! + } } 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 acd76bb9c..c9ac166fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig 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" } From 13a5b8c5f9a2a1076b9d06b1c7bbfca256282a90 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Wed, 20 Jul 2022 21:26:47 +0200 Subject: [PATCH 2/3] Make the state holder stable --- .../java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 323c62dae..be1cc45a0 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -50,6 +51,7 @@ fun rememberNiaAppState( } } +@Stable class NiaAppState( val navController: NavHostController, val windowSizeClass: WindowSizeClass From abe15077093f7f9baedfc0226659abc9f04f4e1a Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Thu, 21 Jul 2022 06:09:49 +0200 Subject: [PATCH 3/3] Add more KDocs and nav args encoding --- .../apps/nowinandroid/ui/NiaAppState.kt | 23 +++++++++++++++---- .../author/navigation/AuthorNavigation.kt | 13 +++++++++-- .../topic/navigation/TopicNavigation.kt | 13 +++++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) 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 index be1cc45a0..076d8d762 100644 --- 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 @@ -67,6 +67,9 @@ class NiaAppState( val shouldShowNavRail: Boolean get() = !shouldShowBottomBar + /** + * Top level destinations to be used in the BottomBar and NavRail + */ val topLevelDestinations: List = listOf( TopLevelDestination( route = ForYouDestination.route, @@ -84,10 +87,19 @@ class NiaAppState( ) ) - // -------------------- - // NAVIGATION LOGIC - // -------------------- - + /** + * 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) { @@ -115,6 +127,9 @@ class NiaAppState( } } +/** + * Stores information about navigation events to be used with JankStats + */ @Composable private fun NavigationTrackingSideEffect(navController: NavHostController) { JankMetricDisposableEffect(navController) { metricsHolder -> 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 d3749335e..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,7 @@ 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 @@ -29,12 +30,20 @@ object AuthorDestination : NiaNavigationDestination { 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 { - return "author_route/$authorIdArg" + 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 { - return entry.arguments?.getString(authorIdArg)!! + val encodedId = entry.arguments?.getString(authorIdArg)!! + return Uri.decode(encodedId) } } 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 2368e713b..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,7 @@ 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 @@ -29,12 +30,20 @@ object TopicDestination : NiaNavigationDestination { 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 { - return "topic_route/$topicIdArg" + 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 { - return entry.arguments?.getString(topicIdArg)!! + val encodedId = entry.arguments?.getString(topicIdArg)!! + return Uri.decode(encodedId) } }