diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 3c1a5eb..bbeb39e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ These are the recipes and what they demonstrate. - **[Dialog](app/src/main/java/com/example/nav3recipes/dialog)**: Shows how to create a Dialog destination. - **[Custom Scene](app/src/main/java/com/example/nav3recipes/scenes/twopane)**: Shows how to create a custom layout using a `Scene` and `SceneStrategy` (see video of UI behavior below). - **[Animations](app/src/main/java/com/example/nav3recipes/animations)**: Override the default animations for all destinations and a single destination. +- **[List-Detail without placeholder](app/src/main/java/com/example/nav3recipes/scenes/listdetailnoplaceholder)**: Shows how to make a list-detail without a placeholder, adapting the number of columns to the window size ### Material adaptive layouts Examples showing how to use the layouts provided by the [Compose Material3 Adaptive Navigation3 library](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#compose_material3_adaptive_navigation3_version_10_2) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcc9d73..a1642a1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,8 @@ dependencies { implementation(libs.androidx.material3.windowsizeclass) implementation(libs.androidx.adaptive.layout) implementation(libs.androidx.material3.navigation3) + implementation(libs.androidx.window) + implementation(libs.androidx.window.core) implementation(libs.kotlinx.serialization.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6413184..e44e0f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,10 @@ android:name=".material.supportingpane.MaterialSupportingPaneActivity" android:exported="true" android:theme="@style/Theme.Nav3Recipes"/> + ( + val firstPane: NavEntry, + val secondPane: NavEntry, + val thirdPane: NavEntry, + val weights: ListDetailNoPlaceholderSceneStrategy.SceneDefaults, + override val previousEntries: List>, + override val key: Any +) : Scene { + + override val entries: List> = listOf(firstPane, secondPane, thirdPane) + + override val content: @Composable (() -> Unit) = { + + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(weights.threePanesSceneFirstPaneWeight)) { + firstPane.Content() + } + Column(modifier = Modifier.weight(weights.threePanesSceneSecondPaneWeight)) { + secondPane.Content() + } + Column(modifier = Modifier.weight(weights.threePanesSceneThirdPaneWeight)) { + thirdPane.Content() + } + } + } +} + +internal class AdaptiveTwoPaneScene( + val firstPane: NavEntry, + val secondPane: NavEntry, + val weights: ListDetailNoPlaceholderSceneStrategy.SceneDefaults, + override val previousEntries: List>, + override val key: Any +) : Scene { + + override val entries: List> = listOf(firstPane, secondPane) + + override val content: @Composable (() -> Unit) = { + + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(weights.twoPanesScenePaneWeight)) { + firstPane.Content() + } + Column(modifier = Modifier.weight(1 - weights.twoPanesScenePaneWeight)) { + secondPane.Content() + } + } + } +} + + +internal class BottomPaneScene( + val pane: NavEntry, + val properties: ModalBottomSheetProperties = ModalBottomSheetProperties(), + override val previousEntries: List>, + override val key: Any, + val onBack: (Int) -> Unit +) : Scene { + + override val entries: List> = listOf(pane) + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (() -> Unit) = { + + ModalBottomSheet( + onDismissRequest = { onBack(1) }, + properties = properties + ) { + pane.Content() + } + + } +} + +class ListDetailNoPlaceholderSceneStrategy(val sceneDefaults: SceneDefaults = SceneDefaults()) : + SceneStrategy { + + companion object { + internal const val MAIN = "main" + internal const val DETAIL = "detail" + internal const val SUPPORT = "support" + internal const val THIRD_PANEL = "thirdPanel" + + @JvmStatic + fun main() = mapOf(MAIN to true) + + @JvmStatic + fun detail() = mapOf(DETAIL to true) + + @JvmStatic + fun thirdPanel() = mapOf(THIRD_PANEL to true) + + @JvmStatic + fun support() = mapOf(SUPPORT to true) + } + + data class SceneDefaults( + val twoPanesScenePaneWeight: Float = .5f, + val threePanesSceneFirstPaneWeight: Float = .4f, + val threePanesSceneSecondPaneWeight: Float = .3f, + val threePanesSceneThirdPaneWeight: Float = .3f, + val bottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties() + ) + + @Composable + override fun calculateScene( + entries: List>, onBack: (Int) -> Unit + ): Scene? { + + val windowSizeClass = + currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass + val isLastEntrySupportingPane = entries.lastOrNull()?.metadata[SUPPORT] == true + + // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes, + // or if a supporting pane is detected. + // + // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp). + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { + return if (isLastEntrySupportingPane) { + buildSupportingPaneScene( + pane = entries.last(), + previousEntry = entries[entries.size - 2], + onBack = onBack + ) + } else { + null + } + } + + if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_LARGE_LOWER_BOUND) && entries.size >= 3) { + return buildAdaptiveThreePanesScene(entries) + } + + if (entries.size >= 2) { + return buildAdaptiveTwoPanesScene(entries) + } + return null + } + + private fun buildAdaptiveThreePanesScene(entries: List>): Scene? { + val lastEntry = entries.last() + val secondLastEntry = entries[entries.size - 2] + val thirdLastEntry = entries[entries.size - 3] + + return if (lastEntry.metadata[THIRD_PANEL] == true && secondLastEntry.metadata[DETAIL] == true && thirdLastEntry.metadata[MAIN] == true) { + AdaptiveThreePaneScene( + firstPane = thirdLastEntry, + secondPane = secondLastEntry, + thirdPane = lastEntry, + weights = sceneDefaults, + previousEntries = listOf(thirdLastEntry, secondLastEntry), + key = Triple( + thirdLastEntry.contentKey, secondLastEntry.contentKey, lastEntry.contentKey + ) + ) + } else { + null + } + } + + private fun NavEntry.isMainPane() : Boolean = metadata[MAIN] == true + private fun NavEntry.isSecondPane() : Boolean = metadata[DETAIL] == true || metadata[SUPPORT] == true + private fun NavEntry.isLastPane() : Boolean = metadata[THIRD_PANEL] == true + + private fun buildAdaptiveTwoPanesScene(entries: List>): Scene? { + val lastEntry = entries.last() + val secondLastEntry = entries[entries.size - 2] + + return if (lastEntry.isSecondPane() && secondLastEntry.isMainPane()) { + buildListDetailScene(secondLastEntry, lastEntry) + } else if (lastEntry.isLastPane() && secondLastEntry.isSecondPane() && entries.size >= 3) { + val zeroethEntry = entries[entries.size - 3] + buildDetailAndThirdPanelScene(secondLastEntry, lastEntry, zeroethEntry) + } else { + null + } + } + + private fun buildListDetailScene(firstEntry: NavEntry, secondEntry: NavEntry): Scene { + return AdaptiveTwoPaneScene( + firstPane = firstEntry, + secondPane = secondEntry, + weights = sceneDefaults, + previousEntries = listOf(firstEntry), + key = Pair(firstEntry.contentKey, secondEntry.contentKey) + ) + } + + private fun buildDetailAndThirdPanelScene( + firstEntry: NavEntry, secondEntry: NavEntry, previousEntry: NavEntry + ): Scene { + return AdaptiveTwoPaneScene( + firstPane = firstEntry, + secondPane = secondEntry, + weights = sceneDefaults, + previousEntries = listOf(previousEntry, firstEntry), + key = Pair(firstEntry.contentKey, secondEntry.contentKey) + ) + } + + private fun buildSupportingPaneScene( + pane: NavEntry, + previousEntry: NavEntry, + onBack: (Int) -> Unit + ): Scene { + return BottomPaneScene( + pane = pane, + properties = sceneDefaults.bottomSheetProperties, + previousEntries = listOf(previousEntry), + key = pane.contentKey, + onBack = onBack + ) + } +} diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdetailnoplaceholder/ListDetailNoPlaceholderActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdetailnoplaceholder/ListDetailNoPlaceholderActivity.kt new file mode 100644 index 0000000..fbe06e0 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdetailnoplaceholder/ListDetailNoPlaceholderActivity.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2025 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.example.nav3recipes.scenes.listdetailnoplaceholder + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.navEntryDecorator +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigation3.ui.NavDisplay +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXTRA_LARGE_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_LARGE_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND +import com.example.nav3recipes.content.ContentBase +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import com.example.nav3recipes.ui.theme.colors +import kotlinx.serialization.Serializable + +/** + * This example shows how to create custom layouts using the Scenes API. + * + * A custom List Detail scene will render in the following way: + * - a single pane with a variable number of columns if no product has been selected + * - a list of products and the product detail, whenever a product is selected and the available + * window width is at least 600 dp + * - the product detail and an additional one if the window width is at least 600 dp + * - all three panes, when available, on bigger window size + * + * + * @see `ListDetailNoPlaceholderScene` + */ +@Serializable +private object Home : NavKey + +@Serializable +private data class Product(val id: Int) : NavKey + +@Serializable +private data object Profile : NavKey + +@Serializable +private data object Toolbar : NavKey + + +@OptIn(ExperimentalMaterial3Api::class) +class ListDetailNoPlaceholderActivity : ComponentActivity() { + + private val mockProducts = List(10) { Product(it) } + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + val localNavSharedTransitionScope: ProvidableCompositionLocal = + compositionLocalOf { + throw IllegalStateException( + "Unexpected access to LocalNavSharedTransitionScope. You must provide a " + + "SharedTransitionScope from a call to SharedTransitionLayout() or " + + "SharedTransitionScope()" + ) + } + + + var numberOfColumns by remember { mutableIntStateOf(1) } + + /** + * A [NavEntryDecorator] that wraps each entry in a shared element that is controlled by the + * [Scene]. + */ + val sharedEntryInSceneNavEntryDecorator = navEntryDecorator { entry -> + with(localNavSharedTransitionScope.current) { + BoxWithConstraints( + Modifier.sharedElement( + rememberSharedContentState(entry.contentKey), + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + ), + ) { + if (entry.metadata.containsKey(ListDetailNoPlaceholderSceneStrategy.MAIN)) { + numberOfColumns = columnsByComposableWidth(maxWidth) + } + entry.Content() + } + } + } + + + val backStack = rememberNavBackStack(Home) + + /** + * A [SceneWeightsDefaults] that wraps variable initial weights to customise the appearance + * of each panel + */ + val defaults = ListDetailNoPlaceholderSceneStrategy.SceneDefaults() + .copy(twoPanesScenePaneWeight = .4f) + val strategy : SceneStrategy = + remember { ListDetailNoPlaceholderSceneStrategy(defaults) } + + SharedTransitionLayout { + CompositionLocalProvider(localNavSharedTransitionScope provides this) { + NavDisplay( + backStack = backStack, + onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } }, + entryDecorators = listOf( + sharedEntryInSceneNavEntryDecorator, + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator() + ), + sceneStrategy = strategy, + entryProvider = entryProvider { + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.main() + ) { + ContentRed("Adaptive List") { + val gridCells = GridCells.Fixed(numberOfColumns) + + LazyVerticalGrid( + columns = gridCells, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + items(mockProducts.size) { + Text( + text = "Product $it", + modifier = Modifier + .padding(all = 16.dp) + .clickable { + backStack.addProductRoute(it) + }) + } + } + + Button( + onClick = { backStack.add(Toolbar) }, + modifier = Modifier.padding(top = 32.dp) + ) { + Text("Open toolbar") + } + } + } + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.detail() + ) { product -> + ContentBase( + "Product ${product.id} ", + Modifier.background(colors[product.id % colors.size]) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { + backStack.addProductRoute(product.id + 1) + }) { + Text("View the next product") + } + Button(onClick = { + backStack.add(Profile) + }) { + Text("View profile") + } + } + } + } + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.thirdPanel() + ) { + ContentGreen("Profile") + } + + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.support() + ) { + ContentBlue("Toolbar") + } + } + ) + } + } + } + } + + private fun NavBackStack.addProductRoute(productId: Int) { + val productRoute = + Product(productId) + + val lastItem = last() + if (lastItem is Product || lastItem is Toolbar) { + // Avoid adding the same product route to the back stack twice. + if (lastItem == productRoute) { + return + } else { + //Only have a single product as detail + remove(lastItem) + add(productRoute) + } + } else { + add(productRoute) + } + } + + /*** + * This function exemplify how to calculate the number of columns for the adaptive list based + * on how much space is available inside the container + */ + fun columnsByComposableWidth(width: Dp): Int { + return when { + width >= WIDTH_DP_EXTRA_LARGE_LOWER_BOUND.dp -> 5 + width >= WIDTH_DP_LARGE_LOWER_BOUND.dp -> 4 + width >= WIDTH_DP_EXPANDED_LOWER_BOUND.dp -> 3 + width >= WIDTH_DP_MEDIUM_LOWER_BOUND.dp -> 2 + else -> 1 + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25a545b..771c123 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ ksp = "2.2.10-2.0.2" hilt = "2.57.1" hiltNavigationCompose = "1.3.0" koin = "4.1.1" +window = "1.5.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -62,6 +63,8 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-material3-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "nav3Material" } +androidx-window = { group = "androidx.window", name = "window", version.ref = "window" } +androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "window" } koin-compose-viewmodel = {group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin"} [plugins]