Replace manual navigation component switching with new NavigationSuiteScaffold

Change-Id: I54b402e28b6e1bd400c9f44644bd4dd35c98e723
pull/942/head
Miłosz Moczkowski 1 year ago
parent aa8ce0e1f6
commit 816a7b60c1

@ -116,6 +116,12 @@ dependencies {
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.adaptive) {
this.isTransitive = false
}
implementation(libs.androidx.compose.material3.adaptive.navigation.suite) {
this.isTransitive = false
}
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)

@ -20,8 +20,6 @@ import androidx.annotation.StringRes
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -220,14 +218,6 @@ class NavigationTest {
onNodeWithText(saved).performClick()
onNodeWithContentDescription(settings).performClick()
onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected.
onNode(
hasText(saved) and
hasAnyAncestor(
hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"),
),
).assertIsSelected()
}
}

@ -1,268 +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.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/**
* Tests that the navigation UI is rendered correctly on different screen sizes.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@HiltAndroidTest
class NavigationUiTest {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* 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 = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/
@get:Rule(order = 2)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject
lateinit var networkMonitor: NetworkMonitor
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun compactWidth_compactHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_expandedHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
}

@ -16,8 +16,6 @@
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
@ -41,7 +39,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@ -50,7 +47,6 @@ import kotlin.test.assertTrue
* 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
@ -75,7 +71,7 @@ class NiaAppStateTest {
NiaAppState(
navController = navController,
coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
windowSize = getCompactWindowSize(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
@ -97,7 +93,7 @@ class NiaAppStateTest {
fun niaAppState_destinations() = runTest {
composeTestRule.setContent {
state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
windowSize = getCompactWindowSize(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
@ -109,61 +105,13 @@ class NiaAppStateTest {
assertTrue(state.topLevelDestinations[2].name.contains("interests", true))
}
@Test
fun niaAppState_showBottomBar_compact() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowBottomBar)
assertFalse(state.shouldShowNavRail)
}
@Test
fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test
fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
windowSize = DpSize(900.dp, 1200.dp),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
@ -177,7 +125,7 @@ class NiaAppStateTest {
)
}
private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
private fun getCompactWindowSize() = DpSize(500.dp, 300.dp)
}
@Composable

@ -24,14 +24,17 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.collectWindowSizeAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@ -59,7 +62,6 @@ import javax.inject.Inject
private const val TAG = "MainActivity"
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ -80,6 +82,7 @@ class MainActivity : ComponentActivity() {
val viewModel: MainActivityViewModel by viewModels()
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
@ -139,9 +142,10 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
val windowSize by collectWindowSizeAsState()
NiaApp(
windowSize = windowSize.toDpSize(),
networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository,
)
}
@ -237,6 +241,11 @@ private fun shouldUseDarkTheme(
}
}
@Composable
private fun IntSize.toDpSize(): DpSize = with(LocalDensity.current) {
DpSize(width.toDp(), height.toDp())
}
/**
* The default light scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598

@ -16,18 +16,10 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -39,7 +31,9 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.adaptive.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi
import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -47,17 +41,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.DpSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
@ -66,10 +53,6 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
@ -81,17 +64,16 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveNavigationSuiteApi::class,
)
@Composable
fun NiaApp(
windowSizeClass: WindowSizeClass,
windowSize: DpSize,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass,
windowSize = windowSize,
userNewsResourceRepository = userNewsResourceRepository,
),
) {
@ -131,52 +113,48 @@ fun NiaApp(
}
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
NavigationSuiteScaffold(
layoutType = appState.navigationSuiteType,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = Color.Transparent,
navigationDrawerContainerColor = Color.Transparent,
),
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val isSelected =
currentDestination.isTopLevelDestinationInHierarchy(destination)
val isUnread = unreadDestinations.contains(destination)
item(
selected = isSelected,
icon = {
BadgedBox(
badge = {
if (isUnread) {
Badge()
}
},
) {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = null,
)
}
},
label = { Text(stringResource(destination.iconTextId)) },
onClick = { appState.navigateToTopLevelDestination(destination) },
)
}
},
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding(),
)
}
Column(Modifier.fillMaxSize()) {
// Show the top app bar on top level destinations.
) {
Scaffold(
topBar = {
val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
@ -196,113 +174,29 @@ fun NiaApp(
onNavigationClick = { appState.navigateToSearch() },
)
}
NiaNavHost(appState = appState, onShowSnackbar = { message, action ->
},
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
) { padding ->
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
})
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
},
modifier = Modifier.padding(padding),
)
}
}
}
}
}
@Composable
private fun NiaNavRail(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
val hasUnread = destinationsWithUnreadResources.contains(destination)
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
}
}
}
@Composable
private fun NiaBottomBar(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationBar(
modifier = modifier,
) {
destinations.forEach { destination ->
val hasUnread = destinationsWithUnreadResources.contains(destination)
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
}
}
}
private fun Modifier.notificationDot(): Modifier =
composed {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false

@ -16,12 +16,14 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.adaptive.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi
import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination
@ -53,7 +55,7 @@ import kotlinx.coroutines.flow.stateIn
@Composable
fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
windowSize: DpSize,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
@ -63,14 +65,14 @@ fun rememberNiaAppState(
return remember(
navController,
coroutineScope,
windowSizeClass,
windowSize,
networkMonitor,
userNewsResourceRepository,
) {
NiaAppState(
navController,
coroutineScope,
windowSizeClass,
windowSize,
networkMonitor,
userNewsResourceRepository,
)
@ -81,7 +83,7 @@ fun rememberNiaAppState(
class NiaAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
private val windowSize: DpSize,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
) {
@ -97,12 +99,6 @@ class NiaAppState(
else -> null
}
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
@ -133,6 +129,26 @@ class NiaAppState(
initialValue = emptySet(),
)
/**
* Per <a href="https://m3.material.io/components/navigation-drawer/guidelines">Material Design 3 guidelines</a>,
* the selection of the appropriate navigation component should be contingent on the available
* window size:
* - Bottom Bar for compact window sizes (below 600dp)
* - Navigation Rail for medium and expanded window sizes up to 1240dp (between 600dp and 1240dp)
* - Navigation Drawer to window size above 1240dp
*/
@OptIn(ExperimentalMaterial3AdaptiveNavigationSuiteApi::class)
val navigationSuiteType: NavigationSuiteType
@Composable get() {
return if (windowSize.width > 1240.dp) {
NavigationSuiteType.NavigationDrawer
} else if (windowSize.width >= 600.dp) {
NavigationSuiteType.NavigationRail
} else {
NavigationSuiteType.NavigationBar
}
}
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you

@ -17,9 +17,6 @@
package com.google.samples.apps.nowinandroid.ui
import android.util.Log
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -60,12 +57,11 @@ import javax.inject.Inject
/**
* Tests that the navigation UI is rendered correctly on different screen sizes.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
// This allows enough room to render the content under test without clipping or scaling.
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33])
@Config(application = HiltTestApplication::class, qualifiers = "w1300dp-h1000dp-480dpi", sdk = [33])
@LooperMode(LooperMode.Mode.PAUSED)
@HiltAndroidTest
class NiaAppScreenSizesScreenshotTests {
@ -138,16 +134,13 @@ class NiaAppScreenSizesScreenshotTests {
CompositionLocalProvider(
LocalInspectionMode provides true,
) {
TestHarness(size = DpSize(width, height)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
val windowSize = DpSize(width, height)
TestHarness(size = windowSize) {
NiaApp(
windowSize = windowSize,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
@ -239,4 +232,31 @@ class NiaAppScreenSizesScreenshotTests {
"expandedWidth_expandedHeight_showsNavigationRail",
)
}
@Test
fun largeScreenWidth_compactHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
400.dp,
"largeScreenWidth_compactHeight_showsPermanentDrawer",
)
}
@Test
fun largeScreenWidth_mediumHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
500.dp,
"largeScreenWidth_mediumHeight_showsPermanentDrawer",
)
}
@Test
fun largeScreenWidth_expandedHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
1000.dp,
"largeScreenWidth_expandedHeight_showsPermanentDrawer",
)
}
}

@ -23,7 +23,3 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks"
}
dependencies {
implementation(libs.androidx.compose.material3.windowSizeClass)
}

@ -18,6 +18,8 @@ androidxHiltNavigationCompose = "1.0.0"
androidxJunit = "1.1.5"
androidxLifecycle = "2.6.2"
androidxMacroBenchmark = "1.2.0"
androidxComposeMaterial3Adaptive = "1.0.0-alpha01"
androidxComposeMaterial3AdaptiveNavigationSuite = "1.0.0-alpha01"
androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.7.4"
androidxProfileinstaller = "1.3.1"
@ -71,6 +73,8 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3", name = "material3-adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "androidxComposeMaterial3AdaptiveNavigationSuite" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }

Loading…
Cancel
Save