parent
c35e530d92
commit
0eee3620b8
@ -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
|
||||||
|
}
|
@ -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
|
|
||||||
)
|
|
||||||
)
|
|
@ -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
|
@ -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<TopLevelDestination> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue