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]