From 816a7b60c1812d0368beea4454c3f629e369aaf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Thu, 16 Nov 2023 17:14:09 +0100 Subject: [PATCH] Replace manual navigation component switching with new NavigationSuiteScaffold Change-Id: I54b402e28b6e1bd400c9f44644bd4dd35c98e723 --- app/build.gradle.kts | 6 + .../apps/nowinandroid/ui/NavigationTest.kt | 10 - .../apps/nowinandroid/ui/NavigationUiTest.kt | 268 ------------------ .../apps/nowinandroid/ui/NiaAppStateTest.kt | 60 +--- .../samples/apps/nowinandroid/MainActivity.kt | 17 +- .../samples/apps/nowinandroid/ui/NiaApp.kt | 222 ++++----------- .../apps/nowinandroid/ui/NiaAppState.kt | 40 ++- .../ui/NiaAppScreenSizesScreenshotTests.kt | 50 +++- feature/bookmarks/build.gradle.kts | 4 - gradle/libs.versions.toml | 4 + 10 files changed, 148 insertions(+), 533 deletions(-) delete mode 100644 app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a3ada333..0a6331c7f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 5861cda58..58672f8e0 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -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() } } diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt deleted file mode 100644 index d92390918..000000000 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ /dev/null @@ -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() - - 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() - } -} diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 1560a74eb..f730a7544 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -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 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index 7fe1bc674..59b3d4184 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index aa85afebd..7c536668e 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -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, - destinationsWithUnreadResources: Set, - 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, - destinationsWithUnreadResources: Set, - 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 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 09e70069e..861252081 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -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 Material Design 3 guidelines, + * 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 diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index bac088482..89be3dc5a 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -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", + ) + } } diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index 32394f911..f381c8a23 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -23,7 +23,3 @@ plugins { android { namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks" } - -dependencies { - implementation(libs.androidx.compose.material3.windowSizeClass) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1eb9116e8..2cff441c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }