Merge pull request #186 from android/mv/state_holder
Add state holder for the NiaApp composablepull/191/head
commit
e53bb89671
@ -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,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.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.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<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
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue