diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 1d600b53d..f443dd667 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -49,8 +49,9 @@ fun NiaNavHost( startDestination = startDestination, modifier = modifier, ) { - // TODO: handle topic clicks from each top level destination - forYouScreen(onTopicClick = {}) + forYouScreen( + onTopicClick = navController::navigateToTopic, + ) bookmarksScreen( onTopicClick = navController::navigateToTopic, onShowSnackbar = onShowSnackbar, @@ -61,15 +62,11 @@ fun NiaNavHost( onTopicClick = navController::navigateToTopic, ) interestsGraph( - onTopicClick = { topicId -> - navController.navigateToTopic(topicId) - }, - nestedGraphs = { - topicScreen( - onBackClick = navController::popBackStack, - onTopicClick = {}, - ) - }, + onTopicClick = navController::navigateToTopic, + ) + topicScreen( + onBackClick = navController::popBackStack, + onTopicClick = navController::navigateToTopic, ) } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/AdaptiveScaffold.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/AdaptiveScaffold.kt deleted file mode 100644 index 6fa6ca03d..000000000 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/AdaptiveScaffold.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2021 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 - * - * http://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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.material3.NavigationDrawerItemDefaults -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailDefaults -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.NavigationRailItemDefaults -import androidx.compose.material3.PermanentDrawerSheet -import androidx.compose.material3.PermanentNavigationDrawer -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.window.layout.WindowMetricsCalculator - -class AdaptiveScaffoldNavigationComponentColors internal constructor( - val railContainerColor: Color, - val drawerContainerColor: Color, - val bottomBarContainerColor: Color, - val contentContainerColor: Color, - val contentColor: Color, - val selectedIconColor: Color, - val selectedTextColor: Color, - val unselectedIconColor: Color, - val unselectedTextColor: Color, - val selectedContainerColor: Color, - val unselectedContainerColor: Color, -) - -object AdaptiveScaffoldNavigationComponentDefaults { - @Composable - fun colors( - railContainerColor: Color = NavigationRailDefaults.ContainerColor, - drawerContainerColor: Color = MaterialTheme.colorScheme.surface, - bottomBarContainerColor: Color = NavigationBarDefaults.containerColor, - contentContainerColor: Color = MaterialTheme.colorScheme.background, - contentColor: Color = contentColorFor(contentContainerColor), - selectedIconColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, - selectedTextColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, - unselectedIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - selectedContainerColor: Color = MaterialTheme.colorScheme.secondaryContainer, - unselectedContainerColor: Color = MaterialTheme.colorScheme.surface, - ) = AdaptiveScaffoldNavigationComponentColors( - railContainerColor = railContainerColor, - drawerContainerColor = drawerContainerColor, - bottomBarContainerColor = bottomBarContainerColor, - contentContainerColor = contentContainerColor, - contentColor = contentColor, - selectedIconColor = selectedIconColor, - selectedTextColor = selectedTextColor, - unselectedIconColor = unselectedIconColor, - unselectedTextColor = unselectedTextColor, - selectedContainerColor = selectedContainerColor, - unselectedContainerColor = unselectedContainerColor, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdaptiveScaffold( - modifier: Modifier = Modifier, - navigationItems: List, - navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, - navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, - isItemSelected: @Composable (item: T) -> Boolean, - onNavigationItemClick: (item: T) -> Unit, - topBar: @Composable () -> Unit = {}, - snackbarHost: @Composable () -> Unit = {}, - colors: AdaptiveScaffoldNavigationComponentColors = AdaptiveScaffoldNavigationComponentDefaults.colors(), - contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, - content: @Composable (padding: PaddingValues) -> Unit, -) { - val context = LocalContext.current - val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context) - val widthDp = metrics.bounds.width() / context.resources.displayMetrics.density - val movableContent = remember(content) { - movableContentOf(content) - } - - when { - widthDp >= 1240f -> { - Scaffold( - modifier = modifier, - topBar = topBar, - snackbarHost = snackbarHost, - containerColor = colors.contentContainerColor, - contentColor = colors.contentColor, - contentWindowInsets = contentWindowInsets, - ) { padding -> - PermanentNavigationDrawer( - drawerContent = { - PermanentDrawerSheet( - drawerContainerColor = colors.drawerContainerColor, - ) { - navigationItems.forEach { item -> - NavigationDrawerItem( - label = { navigationItemTitle(item, isItemSelected(item)) }, - icon = { navigationItemIcon(item, isItemSelected(item)) }, - selected = isItemSelected(item), - onClick = { onNavigationItemClick(item) }, - colors = NavigationDrawerItemDefaults.colors( - selectedContainerColor = colors.selectedContainerColor, - unselectedContainerColor = colors.unselectedContainerColor, - selectedIconColor = colors.selectedIconColor, - unselectedIconColor = colors.unselectedIconColor, - selectedTextColor = colors.selectedTextColor, - unselectedTextColor = colors.unselectedTextColor, - ), - ) - } - } - }, - modifier = Modifier.padding(padding), - ) { - movableContent(padding) - } - } - } - - widthDp >= 600f -> { - Scaffold( - modifier = modifier, - topBar = topBar, - snackbarHost = snackbarHost, - containerColor = colors.contentContainerColor, - contentColor = colors.contentColor, - contentWindowInsets = contentWindowInsets, - ) { padding -> - Row { - NavigationRail( - containerColor = colors.railContainerColor, - ) { - navigationItems.forEach { item -> - NavigationRailItem( - label = { navigationItemTitle(item, isItemSelected(item)) }, - icon = { navigationItemIcon(item, isItemSelected(item)) }, - selected = isItemSelected(item), - onClick = { onNavigationItemClick(item) }, - colors = NavigationRailItemDefaults.colors( - indicatorColor = colors.selectedContainerColor, - selectedIconColor = colors.selectedIconColor, - unselectedIconColor = colors.unselectedIconColor, - selectedTextColor = colors.selectedTextColor, - unselectedTextColor = colors.unselectedTextColor, - ), - ) - } - } - movableContent(padding) - } - } - } - - else -> { - Scaffold( - modifier = modifier, - snackbarHost = snackbarHost, - bottomBar = { - NavigationBar( - containerColor = colors.bottomBarContainerColor, - ) { - navigationItems.forEach { item -> - NavigationBarItem( - label = { navigationItemTitle(item, isItemSelected(item)) }, - icon = { navigationItemIcon(item, isItemSelected(item)) }, - selected = isItemSelected(item), - onClick = { onNavigationItemClick(item) }, - colors = NavigationBarItemDefaults.colors( - indicatorColor = colors.selectedContainerColor, - selectedIconColor = colors.selectedIconColor, - unselectedIconColor = colors.unselectedIconColor, - selectedTextColor = colors.selectedTextColor, - unselectedTextColor = colors.unselectedTextColor, - ), - ) - } - } - }, - containerColor = colors.contentContainerColor, - contentColor = colors.contentColor, - contentWindowInsets = contentWindowInsets, - ) { padding -> - movableContent(padding) - } - } - } -} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index effe22b52..e63b076f0 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration.Indefinite @@ -32,6 +33,7 @@ import androidx.compose.material3.SnackbarHost 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,30 +55,42 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.rememberNavController import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository 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.NiaTopAppBar +import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors +import com.google.samples.apps.nowinandroid.core.ui.AdaptiveScaffold +import com.google.samples.apps.nowinandroid.core.ui.AdaptiveScaffoldNavigationComponentDefaults +import com.google.samples.apps.nowinandroid.core.ui.AdaptiveScaffoldNavigator import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination +import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR @OptIn( ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, + ExperimentalMaterial3Api::class, ) @Composable fun NiaApp( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, + adaptiveScaffoldNavigator: AdaptiveScaffoldNavigator = remember { + AdaptiveScaffoldNavigator() + }, appState: NiaAppState = rememberNiaAppState( networkMonitor = networkMonitor, windowSizeClass = windowSizeClass, userNewsResourceRepository = userNewsResourceRepository, + navController = rememberNavController(adaptiveScaffoldNavigator), ), ) { val shouldShowGradientBackground = @@ -117,6 +131,7 @@ fun NiaApp( val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() AdaptiveScaffold( + navigator = adaptiveScaffoldNavigator, navigationItems = TopLevelDestination.values().toList(), navigationItemTitle = { item, _ -> Text(text = stringResource(id = item.titleTextId)) }, navigationItemIcon = { item, isSelected -> @@ -138,6 +153,28 @@ fun NiaApp( railContainerColor = Color.Transparent, contentContainerColor = Color.Transparent, ), + topBar = { + val titleResId by appState.topBarTitle + val title = titleResId?.let { stringResource(it) } + if (title != null) { + NiaTopAppBar( + title = title, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = stringResource( + id = settingsR.string.top_app_bar_navigation_icon_description, + ), + actionIcon = NiaIcons.Settings, + actionIconContentDescription = stringResource( + id = settingsR.string.top_app_bar_action_icon_description, + ), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + onActionClick = { showSettingsDialog = true }, + onNavigationClick = { appState.navigateToSearch() }, + ) + } + }, contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = Modifier.semantics { diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 09e70069e..dcc340265 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -20,6 +20,8 @@ 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.State +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavController @@ -97,6 +99,21 @@ class NiaAppState( else -> null } + val topBarTitle: State + @Composable get() = produceState(initialValue = null) { + navController.currentBackStackEntryFlow.collect { backStackEntry -> + val topLevelDestination = when (backStackEntry.destination.route) { + forYouNavigationRoute -> FOR_YOU + bookmarksRoute -> BOOKMARKS + interestsRoute -> INTERESTS + else -> null + } + if (topLevelDestination != null) { + value = topLevelDestination.titleTextId + } + } + } + val shouldShowBottomBar: Boolean get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt index 28007a3b1..5a791d7f9 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt @@ -38,7 +38,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons @OptIn(ExperimentalMaterial3Api::class) @Composable fun NiaTopAppBar( - @StringRes titleRes: Int, + title: String, navigationIcon: ImageVector, navigationIconContentDescription: String?, actionIcon: ImageVector, @@ -49,7 +49,7 @@ fun NiaTopAppBar( onActionClick: () -> Unit = {}, ) { CenterAlignedTopAppBar( - title = { Text(text = stringResource(id = titleRes)) }, + title = { Text(text = title) }, navigationIcon = { IconButton(onClick = onNavigationClick) { Icon( @@ -107,7 +107,7 @@ fun NiaTopAppBar( @Composable private fun NiaTopAppBarPreview() { NiaTopAppBar( - titleRes = android.R.string.untitled, + title = stringResource(id = android.R.string.untitled), navigationIcon = NiaIcons.Search, navigationIconContentDescription = "Navigation icon", actionIcon = NiaIcons.MoreVert, diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index b7280e757..f61c0e685 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -46,6 +46,8 @@ dependencies { implementation(project(":core:model")) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.window.manager) implementation(libs.coil.kt) implementation(libs.coil.kt.compose) implementation(libs.kotlinx.datetime) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffold.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffold.kt new file mode 100644 index 000000000..8892f37f8 --- /dev/null +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffold.kt @@ -0,0 +1,414 @@ +/* + * Copyright 2021 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.core.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailDefaults +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.PermanentDrawerSheet +import androidx.compose.material3.PermanentNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.window.layout.WindowMetricsCalculator + +class AdaptiveScaffoldNavigationComponentColors internal constructor( + val detailsPaneContainerColor: Color, + val railContainerColor: Color, + val drawerContainerColor: Color, + val bottomBarContainerColor: Color, + val contentContainerColor: Color, + val contentColor: Color, + val selectedIconColor: Color, + val selectedTextColor: Color, + val unselectedIconColor: Color, + val unselectedTextColor: Color, + val selectedContainerColor: Color, + val unselectedContainerColor: Color, +) + +object AdaptiveScaffoldNavigationComponentDefaults { + @Composable + fun colors( + detailsPaneContainerColor: Color = NavigationBarDefaults.containerColor, + railContainerColor: Color = NavigationRailDefaults.ContainerColor, + drawerContainerColor: Color = MaterialTheme.colorScheme.surface, + bottomBarContainerColor: Color = NavigationBarDefaults.containerColor, + contentContainerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(contentContainerColor), + selectedIconColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + unselectedIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + unselectedContainerColor: Color = MaterialTheme.colorScheme.surface, + ) = AdaptiveScaffoldNavigationComponentColors( + detailsPaneContainerColor = detailsPaneContainerColor, + railContainerColor = railContainerColor, + drawerContainerColor = drawerContainerColor, + bottomBarContainerColor = bottomBarContainerColor, + contentContainerColor = contentContainerColor, + contentColor = contentColor, + selectedIconColor = selectedIconColor, + selectedTextColor = selectedTextColor, + unselectedIconColor = unselectedIconColor, + unselectedTextColor = unselectedTextColor, + selectedContainerColor = selectedContainerColor, + unselectedContainerColor = unselectedContainerColor, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveScaffold( + modifier: Modifier = Modifier, + navigationItems: List, + navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, + navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, + isItemSelected: @Composable (item: T) -> Boolean, + onNavigationItemClick: (item: T) -> Unit, + topBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + colors: AdaptiveScaffoldNavigationComponentColors = AdaptiveScaffoldNavigationComponentDefaults.colors(), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + isDetailsPaneVisible: Boolean = false, + detailsPane: @Composable () -> Unit = {}, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val context = LocalContext.current + val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context) + val widthDp = metrics.bounds.width() / context.resources.displayMetrics.density + val movableContent = remember(content) { + movableContentOf(content) + } + + when { + widthDp >= 1240f -> DrawerScaffold( + modifier = modifier, + navigationItems = navigationItems, + navigationItemTitle = navigationItemTitle, + navigationItemIcon = navigationItemIcon, + isItemSelected = isItemSelected, + onNavigationItemClick = onNavigationItemClick, + topBar = topBar, + snackbarHost = snackbarHost, + colors = colors, + contentWindowInsets = contentWindowInsets, + isDetailsPaneVisible = isDetailsPaneVisible, + detailsPane = detailsPane, + content = movableContent, + ) + + widthDp >= 600f -> RailScaffold( + modifier = modifier, + navigationItems = navigationItems, + navigationItemTitle = navigationItemTitle, + navigationItemIcon = navigationItemIcon, + isItemSelected = isItemSelected, + onNavigationItemClick = onNavigationItemClick, + topBar = topBar, + snackbarHost = snackbarHost, + colors = colors, + contentWindowInsets = contentWindowInsets, + isDetailsPaneVisible = isDetailsPaneVisible, + detailsPane = detailsPane, + content = movableContent, + ) + + else -> BottomBarScaffold( + modifier = modifier, + navigationItems = navigationItems, + navigationItemTitle = navigationItemTitle, + navigationItemIcon = navigationItemIcon, + isItemSelected = isItemSelected, + onNavigationItemClick = onNavigationItemClick, + topBar = topBar, + snackbarHost = snackbarHost, + colors = colors, + contentWindowInsets = contentWindowInsets, + isDetailsPaneVisible = isDetailsPaneVisible, + detailsPane = detailsPane, + content = movableContent, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomBarScaffold( + modifier: Modifier, + navigationItems: List, + navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, + navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, + isItemSelected: @Composable (item: T) -> Boolean, + onNavigationItemClick: (item: T) -> Unit, + topBar: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit, + colors: AdaptiveScaffoldNavigationComponentColors, + contentWindowInsets: WindowInsets, + isDetailsPaneVisible: Boolean, + detailsPane: @Composable () -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + Box(modifier = modifier) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = topBar, + snackbarHost = snackbarHost, + bottomBar = { + NavigationBar( + containerColor = colors.bottomBarContainerColor, + ) { + navigationItems.forEach { item -> + NavigationBarItem( + label = { navigationItemTitle(item, isItemSelected(item)) }, + icon = { navigationItemIcon(item, isItemSelected(item)) }, + selected = isItemSelected(item), + onClick = { onNavigationItemClick(item) }, + colors = NavigationBarItemDefaults.colors( + indicatorColor = colors.selectedContainerColor, + selectedIconColor = colors.selectedIconColor, + unselectedIconColor = colors.unselectedIconColor, + selectedTextColor = colors.selectedTextColor, + unselectedTextColor = colors.unselectedTextColor, + ), + ) + } + } + }, + containerColor = colors.contentContainerColor, + contentColor = colors.contentColor, + contentWindowInsets = contentWindowInsets, + ) { padding -> + content(padding) + } + AnimatedVisibility( + visible = isDetailsPaneVisible, + enter = slideInHorizontally(initialOffsetX = { it }), + exit = slideOutHorizontally(targetOffsetX = { it }), + modifier = Modifier.fillMaxSize(), + ) { + Surface(color = colors.detailsPaneContainerColor) { + detailsPane() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RailScaffold( + modifier: Modifier, + navigationItems: List, + navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, + navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, + isItemSelected: @Composable (item: T) -> Boolean, + onNavigationItemClick: (item: T) -> Unit, + topBar: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit, + colors: AdaptiveScaffoldNavigationComponentColors, + contentWindowInsets: WindowInsets, + isDetailsPaneVisible: Boolean, + detailsPane: @Composable () -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val weight: Float by animateFloatAsState( + targetValue = if (isDetailsPaneVisible) 0.5f else 1f, + animationSpec = tween(durationMillis = 500), + label = "Details Pane", + ) + + Row(modifier = modifier) { + NavigationRail( + containerColor = colors.railContainerColor, + ) { + navigationItems.forEach { item -> + NavigationRailItem( + label = { navigationItemTitle(item, isItemSelected(item)) }, + icon = { navigationItemIcon(item, isItemSelected(item)) }, + selected = isItemSelected(item), + onClick = { onNavigationItemClick(item) }, + colors = NavigationRailItemDefaults.colors( + indicatorColor = colors.selectedContainerColor, + selectedIconColor = colors.selectedIconColor, + unselectedIconColor = colors.unselectedIconColor, + selectedTextColor = colors.selectedTextColor, + unselectedTextColor = colors.unselectedTextColor, + ), + ) + } + } + Scaffold( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(weight), + topBar = topBar, + snackbarHost = snackbarHost, + containerColor = colors.contentContainerColor, + contentColor = colors.contentColor, + contentWindowInsets = contentWindowInsets, + ) { padding -> + content(padding) + } + Surface( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + color = colors.detailsPaneContainerColor, + ) { + detailsPane() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DrawerScaffold( + modifier: Modifier, + navigationItems: List, + navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, + navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, + isItemSelected: @Composable (item: T) -> Boolean, + onNavigationItemClick: (item: T) -> Unit, + topBar: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit, + colors: AdaptiveScaffoldNavigationComponentColors, + contentWindowInsets: WindowInsets, + isDetailsPaneVisible: Boolean, + detailsPane: @Composable () -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val weight: Float by animateFloatAsState( + targetValue = if (isDetailsPaneVisible) 0.5f else 1f, + animationSpec = tween(durationMillis = 500), + label = "Details Pane", + ) + + Row(modifier = modifier) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(weight), + topBar = topBar, + snackbarHost = snackbarHost, + containerColor = colors.contentContainerColor, + contentColor = colors.contentColor, + contentWindowInsets = contentWindowInsets, + ) { padding -> + PermanentNavigationDrawer( + drawerContent = { + PermanentDrawerSheet( + drawerContainerColor = colors.drawerContainerColor, + ) { + navigationItems.forEach { item -> + NavigationDrawerItem( + label = { navigationItemTitle(item, isItemSelected(item)) }, + icon = { navigationItemIcon(item, isItemSelected(item)) }, + selected = isItemSelected(item), + onClick = { onNavigationItemClick(item) }, + colors = NavigationDrawerItemDefaults.colors( + selectedContainerColor = colors.selectedContainerColor, + unselectedContainerColor = colors.unselectedContainerColor, + selectedIconColor = colors.selectedIconColor, + unselectedIconColor = colors.unselectedIconColor, + selectedTextColor = colors.selectedTextColor, + unselectedTextColor = colors.unselectedTextColor, + ), + ) + } + } + }, + modifier = Modifier.padding(padding), + ) { + content(padding) + } + } + Surface( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + color = colors.detailsPaneContainerColor, + ) { + detailsPane() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveScaffold( + navigator: AdaptiveScaffoldNavigator, + modifier: Modifier = Modifier, + navigationItems: List, + navigationItemTitle: @Composable (item: T, isSelected: Boolean) -> Unit, + navigationItemIcon: @Composable (item: T, isSelected: Boolean) -> Unit, + isItemSelected: @Composable (item: T) -> Boolean, + onNavigationItemClick: (item: T) -> Unit, + topBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + colors: AdaptiveScaffoldNavigationComponentColors = AdaptiveScaffoldNavigationComponentDefaults.colors(), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val isDetailsPaneVisible by navigator.isVisible.collectAsState(initial = false) + AdaptiveScaffold( + modifier = modifier, + navigationItems = navigationItems, + navigationItemTitle = navigationItemTitle, + navigationItemIcon = navigationItemIcon, + isItemSelected = isItemSelected, + onNavigationItemClick = onNavigationItemClick, + topBar = topBar, + snackbarHost = snackbarHost, + colors = colors, + contentWindowInsets = contentWindowInsets, + isDetailsPaneVisible = isDetailsPaneVisible, + detailsPane = navigator.detailsPaneContent, + content = content, + ) +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffoldNavigator.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffoldNavigator.kt new file mode 100644 index 000000000..25a0abadb --- /dev/null +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AdaptiveScaffoldNavigator.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2023 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.core.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.navigation.FloatingWindow +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.NavigatorState +import androidx.navigation.compose.LocalOwnersProvider +import androidx.navigation.get +import com.google.samples.apps.nowinandroid.core.ui.AdaptiveScaffoldNavigator.Destination +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +@Navigator.Name("AdaptiveScaffoldNavigator") +class AdaptiveScaffoldNavigator : Navigator() { + + private var attached = MutableStateFlow(false) + + private val backStack: Flow> = attached.flatMapLatest { + if (it) { + state.backStack + } else { + MutableStateFlow(emptyList()) + } + } + + val isVisible = backStack.map { it.isNotEmpty() } + + val detailsPaneContent: @Composable () -> Unit = { + val saveableStateHolder = rememberSaveableStateHolder() + val currentBackStackEntry: NavBackStackEntry? by produceState( + initialValue = null, + key1 = backStack, + ) { + backStack.map { backstack -> + backstack.lastOrNull() + }.collect { backStackEntry -> + value = backStackEntry + } + } + val backStackEntry = currentBackStackEntry + backStackEntry?.LocalOwnersProvider(saveableStateHolder) { + val destination = (backStackEntry.destination as? Destination) + destination?.content?.invoke(backStackEntry) + } + } + + override fun onAttach(state: NavigatorState) { + super.onAttach(state) + attached.value = true + } + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Extras?, + ) { + entries.forEach { entry -> + state.pushWithTransition(entry) + } + } + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + } + + @NavDestination.ClassType(Composable::class) + class Destination( + navigator: AdaptiveScaffoldNavigator, + internal val content: @Composable (NavBackStackEntry) -> Unit, + ) : NavDestination(navigator), FloatingWindow + + override fun createDestination(): Destination = Destination( + this, + ) { /* no-op */ } +} + +fun NavGraphBuilder.detailsPane( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable (backstackEntry: NavBackStackEntry) -> Unit, +) { + addDestination( + Destination( + provider[AdaptiveScaffoldNavigator::class], + content, + ).apply { + this.route = route + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + }, + ) +} diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt index bef6987f4..9b6c24310 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt @@ -32,7 +32,6 @@ fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { fun NavGraphBuilder.interestsGraph( onTopicClick: (String) -> Unit, - nestedGraphs: NavGraphBuilder.() -> Unit, ) { navigation( route = interestsGraphRoutePattern, @@ -41,6 +40,5 @@ fun NavGraphBuilder.interestsGraph( composable(route = interestsRoute) { InterestsRoute(onTopicClick) } - nestedGraphs() } } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index 0954a52ac..fb0f1c651 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -22,9 +22,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType -import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder +import com.google.samples.apps.nowinandroid.core.ui.detailsPane import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute @VisibleForTesting @@ -46,7 +46,7 @@ fun NavGraphBuilder.topicScreen( onBackClick: () -> Unit, onTopicClick: (String) -> Unit, ) { - composable( + detailsPane( route = "topic_route/{$topicIdArg}", arguments = listOf( navArgument(topicIdArg) { type = NavType.StringType },