From d1e7fd0673383bb13fe9a1294307518d2ace6a07 Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 08:24:23 +0000 Subject: [PATCH 1/6] First commit for List-Detail with variable number of columns --- app/build.gradle.kts | 2 + .../ListDetailNoPlaceholderScene.kt | 100 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 3 files changed, 105 insertions(+) create mode 100644 app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1b2051..e181be5 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/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt new file mode 100644 index 0000000..0627b31 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt @@ -0,0 +1,100 @@ +package com.example.nav3recipes.scenes.listdeailnoplaceholder + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.Scene +import androidx.navigation3.ui.SceneStrategy +import androidx.window.core.layout.WindowSizeClass.Companion.BREAKPOINTS_V2 +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 androidx.window.layout.WindowMetrics + +@Composable +fun columnsBySize() : Int { + val info = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass + + return when { + info.isWidthAtLeastBreakpoint(WIDTH_DP_EXTRA_LARGE_LOWER_BOUND) -> 5 + info.isWidthAtLeastBreakpoint(WIDTH_DP_LARGE_LOWER_BOUND) -> 4 + info.isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) -> 3 + info.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) -> 2 + else -> 1 + } +} + +class ListDetailNoPlaceholderScene( + override val entries: List>, + override val previousEntries: List>, + override val key: Any, + private val columns: Int +) : Scene { + + override val content: @Composable (() -> Unit) = { + if (previousEntries.isNotEmpty()) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(0.5f)) { + previousEntries.last().Content() + } + Column(modifier = Modifier.weight(0.5f)) { + entries.last().Content() + } + } + } else { + entries.last().Content() + } + } + + companion object { + internal const val LIST = "list" + internal const val DETAIL = "detail" + internal const val COLUMNS_KEY = "columns" + + fun list() = mapOf(LIST to true) + fun detail() = mapOf(DETAIL to true) + fun columns(count: Int) = mapOf(COLUMNS_KEY to count) + } +} + +class ListDetailNoPlaceholderSceneStrategy : SceneStrategy{ + @Composable + override fun calculateScene( + entries: List>, + onBack: (Int) -> Unit + ): Scene? { + val columns = columnsBySize() + + if (entries.size >= 2) { + val lastEntry = entries.last() + val secondLastEntry = entries[entries.size - 2] + if (lastEntry.metadata[ListDetailNoPlaceholderScene.DETAIL] == true && + secondLastEntry.metadata[ListDetailNoPlaceholderScene.LIST] == true) { + return ListDetailNoPlaceholderScene( + entries = listOf(lastEntry), + previousEntries = listOf(secondLastEntry), + key = "list_detail_scene", + columns = columns + ) + } + } + if (entries.isNotEmpty()) { + val lastEntry = entries.last() + if (lastEntry.metadata[ListDetailNoPlaceholderScene.LIST] == true) { + return ListDetailNoPlaceholderScene( + entries = listOf(lastEntry), + previousEntries = emptyList(), + key = "list_only_scene", + columns = columns + ) + } + } + return null + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e4edca..4931f8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ nav3Material = "1.0.0-SNAPSHOT" ksp = "2.2.0-2.0.2" hilt = "2.57" hiltNavigationCompose = "1.2.0" +window = "1.5.0-rc01" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,6 +60,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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From d019a52756c04a8b1e2c53a81bdb4192b97a0ff2 Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 14:48:39 +0200 Subject: [PATCH 2/6] Started strategy for Three Panes List-List-Detail --- app/src/main/AndroidManifest.xml | 4 + .../ListDetailNoPlaceholderActivity.kt | 203 ++++++++++++++++++ .../ListDetailNoPlaceholderScene.kt | 77 +++++-- 3 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4040165..659a8b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,10 @@ android:name=".scenes.materiallistdetail.MaterialListDetailActivity" android:exported="true" android:theme="@style/Theme.Nav3Recipes"/> + = + compositionLocalOf { + throw IllegalStateException( + "Unexpected access to LocalNavSharedTransitionScope. You must provide a " + + "SharedTransitionScope from a call to SharedTransitionLayout() or " + + "SharedTransitionScope()" + ) + } + + /** + * A [NavEntryDecorator] that wraps each entry in a shared element that is controlled by the + * [Scene]. + */ + val sharedEntryInSceneNavEntryDecorator = navEntryDecorator { entry -> + with(localNavSharedTransitionScope.current) { + Box( + Modifier.sharedElement( + rememberSharedContentState(entry.contentKey), + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + ), + ) { + entry.Content() + } + } + } + + val defaultNumberOfColumns = columnsBySize() + var numberOfColumns by remember { mutableIntStateOf(defaultNumberOfColumns) } + + val adaptiveContentDecorator = navEntryDecorator { entry -> + BoxWithConstraints { + numberOfColumns = columnsByComposableWidth(maxWidth) + entry.Content() + } + } + + + val backStack = rememberNavBackStack(Home) + val strategy = remember { ListDetailNoPlaceholderSceneStrategy() } + + SharedTransitionLayout { + CompositionLocalProvider(localNavSharedTransitionScope provides this) { + NavDisplay( + backStack = backStack, + onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } }, + entryDecorators = listOf( + sharedEntryInSceneNavEntryDecorator, + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + adaptiveContentDecorator + ), + sceneStrategy = strategy, + entryProvider = entryProvider { + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.Companion.list() + ) { + ContentRed("Adaptive List") { + val gridCells = GridCells.Fixed(numberOfColumns) + + LazyVerticalGrid(columns = gridCells, modifier = Modifier.fillMaxSize()) { + items(mockProducts.size) { + Text(text = "Product $it", modifier = Modifier.clickable{ + backStack.addProductRoute(1) + }) + } + } + } + } + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.Companion.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 { + ContentGreen("Profile (single pane only)") + } + } + ) + } + } + } + } + + private fun SnapshotStateList.addProductRoute(productId: Int) { + val productRoute = + Product(productId) + // Avoid adding the same product route to the back stack twice. + if (!contains(productRoute)) { + add(productRoute) + } + } +} diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt index 0627b31..3e6c4e5 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt @@ -7,9 +7,14 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.navEntryDecorator import androidx.navigation3.ui.Scene import androidx.navigation3.ui.SceneStrategy +import androidx.navigation3.ui.SinglePaneSceneStrategy import androidx.window.core.layout.WindowSizeClass.Companion.BREAKPOINTS_V2 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 @@ -18,7 +23,7 @@ import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOW import androidx.window.layout.WindowMetrics @Composable -fun columnsBySize() : Int { +fun columnsBySize(): Int { val info = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass return when { @@ -30,27 +35,54 @@ fun columnsBySize() : Int { } } +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 + } +} + class ListDetailNoPlaceholderScene( - override val entries: List>, + val list: NavEntry, + val detail: NavEntry, override val previousEntries: List>, override val key: Any, private val columns: Int ) : Scene { + override val entries: List> = listOf(list, detail) + override val content: @Composable (() -> Unit) = { - if (previousEntries.isNotEmpty()) { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(0.5f)) { - previousEntries.last().Content() - } - Column(modifier = Modifier.weight(0.5f)) { - entries.last().Content() - } + + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(0.5f)) { + list.Content() + } + Column(modifier = Modifier.weight(0.5f)) { + detail.Content() } - } else { - entries.last().Content() } } +} + +class ListNoPlaceholderScene( + val list: NavEntry, + override val previousEntries: List>, + override val key: Any, + private val columns: Int +) : Scene { + + override val entries: List> = listOf(list) + + override val content: @Composable (() -> Unit) = { + list.Content() + } +} + +class ListDetailNoPlaceholderSceneStrategy : SceneStrategy { companion object { internal const val LIST = "list" @@ -59,11 +91,8 @@ class ListDetailNoPlaceholderScene( fun list() = mapOf(LIST to true) fun detail() = mapOf(DETAIL to true) - fun columns(count: Int) = mapOf(COLUMNS_KEY to count) } -} -class ListDetailNoPlaceholderSceneStrategy : SceneStrategy{ @Composable override fun calculateScene( entries: List>, @@ -74,10 +103,12 @@ class ListDetailNoPlaceholderSceneStrategy : SceneStrategy{ if (entries.size >= 2) { val lastEntry = entries.last() val secondLastEntry = entries[entries.size - 2] - if (lastEntry.metadata[ListDetailNoPlaceholderScene.DETAIL] == true && - secondLastEntry.metadata[ListDetailNoPlaceholderScene.LIST] == true) { + if (lastEntry.metadata[DETAIL] == true && + secondLastEntry.metadata[LIST] == true + ) { return ListDetailNoPlaceholderScene( - entries = listOf(lastEntry), + list = secondLastEntry, + detail = lastEntry, previousEntries = listOf(secondLastEntry), key = "list_detail_scene", columns = columns @@ -86,11 +117,11 @@ class ListDetailNoPlaceholderSceneStrategy : SceneStrategy{ } if (entries.isNotEmpty()) { val lastEntry = entries.last() - if (lastEntry.metadata[ListDetailNoPlaceholderScene.LIST] == true) { - return ListDetailNoPlaceholderScene( - entries = listOf(lastEntry), - previousEntries = emptyList(), - key = "list_only_scene", + if (lastEntry.metadata[LIST] == true) { + return ListNoPlaceholderScene( + list = lastEntry, + previousEntries = entries.dropLast(1), + key = "list_scene", columns = columns ) } From a420ca11e5b3d5330d11f01ba75ca8ba7e7c5eb7 Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 15:38:05 +0200 Subject: [PATCH 3/6] Initial behaviour - TODO: select multiple items --- .../ListDetailNoPlaceholderActivity.kt | 45 ++++++++++--------- .../ListDetailNoPlaceholderScene.kt | 30 +++++++------ 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt index 66d9d39..bca1128 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.Button @@ -42,7 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider @@ -85,7 +86,7 @@ private data object Profile : NavKey class ListDetailNoPlaceholderActivity : ComponentActivity() { - private val mockProducts = List(100) { Product(it) } + private val mockProducts = List(10) { Product(it) } @OptIn(ExperimentalSharedTransitionApi::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -93,6 +94,7 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { + //val defaultNumberOfColumns = columnsBySize() val localNavSharedTransitionScope: ProvidableCompositionLocal = compositionLocalOf { throw IllegalStateException( @@ -102,36 +104,33 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { ) } + + 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) { - Box( + BoxWithConstraints( Modifier.sharedElement( rememberSharedContentState(entry.contentKey), animatedVisibilityScope = LocalNavAnimatedContentScope.current, ), ) { + if (entry.metadata.containsKey(ListDetailNoPlaceholderSceneStrategy.LIST)) { + numberOfColumns = columnsByComposableWidth(maxWidth) + } entry.Content() } } } - val defaultNumberOfColumns = columnsBySize() - var numberOfColumns by remember { mutableIntStateOf(defaultNumberOfColumns) } - - val adaptiveContentDecorator = navEntryDecorator { entry -> - BoxWithConstraints { - numberOfColumns = columnsByComposableWidth(maxWidth) - entry.Content() - } - } - val backStack = rememberNavBackStack(Home) - val strategy = remember { ListDetailNoPlaceholderSceneStrategy() } + val strategy = + remember { ListDetailNoPlaceholderSceneStrategy(listInitialWeight = .5f) } SharedTransitionLayout { CompositionLocalProvider(localNavSharedTransitionScope provides this) { @@ -141,8 +140,7 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { entryDecorators = listOf( sharedEntryInSceneNavEntryDecorator, rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - adaptiveContentDecorator + rememberSavedStateNavEntryDecorator() ), sceneStrategy = strategy, entryProvider = entryProvider { @@ -152,11 +150,18 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { ContentRed("Adaptive List") { val gridCells = GridCells.Fixed(numberOfColumns) - LazyVerticalGrid(columns = gridCells, modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = gridCells, + modifier = Modifier.fillMaxSize() + ) { items(mockProducts.size) { - Text(text = "Product $it", modifier = Modifier.clickable{ - backStack.addProductRoute(1) - }) + Text( + text = "Product $it", + modifier = Modifier + .padding(all = 16.dp) + .clickable { + backStack.addProductRoute(it) + }) } } } diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt index 3e6c4e5..37a8c1b 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavEntry @@ -48,9 +49,10 @@ fun columnsByComposableWidth(width: Dp): Int { class ListDetailNoPlaceholderScene( val list: NavEntry, val detail: NavEntry, + val listWeight: Float = 0.5f, + val detailWeight: Float = 0.5f, override val previousEntries: List>, - override val key: Any, - private val columns: Int + override val key: Any ) : Scene { override val entries: List> = listOf(list, detail) @@ -58,10 +60,10 @@ class ListDetailNoPlaceholderScene( override val content: @Composable (() -> Unit) = { Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(0.5f)) { + Column(modifier = Modifier.weight(listWeight)) { list.Content() } - Column(modifier = Modifier.weight(0.5f)) { + Column(modifier = Modifier.weight(detailWeight)) { detail.Content() } } @@ -71,8 +73,7 @@ class ListDetailNoPlaceholderScene( class ListNoPlaceholderScene( val list: NavEntry, override val previousEntries: List>, - override val key: Any, - private val columns: Int + override val key: Any ) : Scene { override val entries: List> = listOf(list) @@ -82,12 +83,12 @@ class ListNoPlaceholderScene( } } -class ListDetailNoPlaceholderSceneStrategy : SceneStrategy { +class ListDetailNoPlaceholderSceneStrategy(val listInitialWeight: Float = 0.5f) : + SceneStrategy { companion object { internal const val LIST = "list" internal const val DETAIL = "detail" - internal const val COLUMNS_KEY = "columns" fun list() = mapOf(LIST to true) fun detail() = mapOf(DETAIL to true) @@ -98,7 +99,10 @@ class ListDetailNoPlaceholderSceneStrategy : SceneStrategy { entries: List>, onBack: (Int) -> Unit ): Scene? { - val columns = columnsBySize() + + if(listInitialWeight > 1f) { + throw IllegalArgumentException("listInitialWeight must be less than or equal to 1f") + } if (entries.size >= 2) { val lastEntry = entries.last() @@ -109,9 +113,10 @@ class ListDetailNoPlaceholderSceneStrategy : SceneStrategy { return ListDetailNoPlaceholderScene( list = secondLastEntry, detail = lastEntry, + listWeight = listInitialWeight, + detailWeight = 1f - listInitialWeight, previousEntries = listOf(secondLastEntry), - key = "list_detail_scene", - columns = columns + key = Pair(secondLastEntry.contentKey, lastEntry.contentKey) ) } } @@ -121,8 +126,7 @@ class ListDetailNoPlaceholderSceneStrategy : SceneStrategy { return ListNoPlaceholderScene( list = lastEntry, previousEntries = entries.dropLast(1), - key = "list_scene", - columns = columns + key = lastEntry.contentKey ) } } From 3d174d9056f4e6538fb5f0cb291e32ea1a3bdaf2 Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 15:47:16 +0200 Subject: [PATCH 4/6] Initial behaviour - TODO: select multiple items --- .../listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt index bca1128..435ed90 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.Button import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf @@ -94,7 +95,8 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { - //val defaultNumberOfColumns = columnsBySize() + + //val wsc = currentWindowAdaptiveInfo().windowSizeClass val localNavSharedTransitionScope: ProvidableCompositionLocal = compositionLocalOf { throw IllegalStateException( From 356b7790a528d7e8c70be1f4752a344d8f9c99ad Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 16:36:18 +0200 Subject: [PATCH 5/6] Select multiple items, but only show one in the backstack --- .../ListDetailNoPlaceholderActivity.kt | 15 +++++++++++--- .../ListDetailNoPlaceholderScene.kt | 8 ++++++++ gradle/libs.versions.toml | 20 +++++++++---------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt index 435ed90..9af55b1 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt @@ -96,7 +96,6 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { setContent { - //val wsc = currentWindowAdaptiveInfo().windowSizeClass val localNavSharedTransitionScope: ProvidableCompositionLocal = compositionLocalOf { throw IllegalStateException( @@ -202,8 +201,18 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { private fun SnapshotStateList.addProductRoute(productId: Int) { val productRoute = Product(productId) - // Avoid adding the same product route to the back stack twice. - if (!contains(productRoute)) { + + val lastItem = last() + if(lastItem is Product) { + // 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) } } diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt index 37a8c1b..3d0ca50 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt @@ -104,6 +104,14 @@ class ListDetailNoPlaceholderSceneStrategy(val listInitialWeight: Float throw IllegalArgumentException("listInitialWeight must be less than or equal to 1f") } + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes. + // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp). + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { + return null + } + if (entries.size >= 2) { val lastEntry = entries.last() val secondLastEntry = entries[entries.size - 2] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4931f8f..8b348f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,23 +13,23 @@ # limitations under the License. [versions] -agp = "8.10.1" -kotlin = "2.2.0" -kotlinSerialization = "2.2.0" -coreKtx = "1.16.0" +agp = "8.11.1" +kotlin = "2.2.10" +kotlinSerialization = "2.2.10" +coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" kotlinxSerializationCore = "1.9.0" -lifecycleRuntimeKtx = "2.9.2" +lifecycleRuntimeKtx = "2.9.3" lifecycleViewmodel = "1.0.0-SNAPSHOT" -activityCompose = "1.12.0-alpha05" -composeBom = "2025.07.01" -navigation3 = "1.0.0-alpha07" -material3 = "1.4.0-beta01" +activityCompose = "1.12.0-alpha07" +composeBom = "2025.08.01" +navigation3 = "1.0.0-alpha08" +material3 = "1.4.0-beta03" nav3Material = "1.0.0-SNAPSHOT" ksp = "2.2.0-2.0.2" -hilt = "2.57" +hilt = "2.57.1" hiltNavigationCompose = "1.2.0" window = "1.5.0-rc01" From a18d4ea325204d8625a58a9dfc8be906efc03b31 Mon Sep 17 00:00:00 2001 From: Rob Orgiu Date: Fri, 5 Sep 2025 17:12:52 +0200 Subject: [PATCH 6/6] Add possibility to show third panel with the second. Crashing when going back from profile. --- .idea/vcs.xml | 2 +- .../ListDetailNoPlaceholderActivity.kt | 6 +- .../ListDetailNoPlaceholderScene.kt | 97 +++++++++++++------ 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt index 9af55b1..fd40575 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderActivity.kt @@ -188,8 +188,10 @@ class ListDetailNoPlaceholderActivity : ComponentActivity() { } } } - entry { - ContentGreen("Profile (single pane only)") + entry( + metadata = ListDetailNoPlaceholderSceneStrategy.thirdPanel() + ) { + ContentGreen("Profile") } } ) diff --git a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt index 3d0ca50..e3cc063 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/listdeailnoplaceholder/ListDetailNoPlaceholderScene.kt @@ -4,24 +4,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavEntryDecorator -import androidx.navigation3.runtime.navEntryDecorator import androidx.navigation3.ui.Scene import androidx.navigation3.ui.SceneStrategy -import androidx.navigation3.ui.SinglePaneSceneStrategy -import androidx.window.core.layout.WindowSizeClass.Companion.BREAKPOINTS_V2 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 androidx.window.layout.WindowMetrics @Composable fun columnsBySize(): Int { @@ -89,9 +82,12 @@ class ListDetailNoPlaceholderSceneStrategy(val listInitialWeight: Float companion object { internal const val LIST = "list" internal const val DETAIL = "detail" + internal const val THIRD_PANEL = "thirdPanel" fun list() = mapOf(LIST to true) fun detail() = mapOf(DETAIL to true) + + fun thirdPanel() = mapOf(THIRD_PANEL to true) } @Composable @@ -100,7 +96,7 @@ class ListDetailNoPlaceholderSceneStrategy(val listInitialWeight: Float onBack: (Int) -> Unit ): Scene? { - if(listInitialWeight > 1f) { + if (listInitialWeight > 1f) { throw IllegalArgumentException("listInitialWeight must be less than or equal to 1f") } @@ -112,32 +108,73 @@ class ListDetailNoPlaceholderSceneStrategy(val listInitialWeight: Float return null } + //if(entries.size >= 3 && windowSizeClass.isWidthAtLeastBreakpoint()) + if (entries.size >= 2) { - val lastEntry = entries.last() - val secondLastEntry = entries[entries.size - 2] - if (lastEntry.metadata[DETAIL] == true && - secondLastEntry.metadata[LIST] == true - ) { - return ListDetailNoPlaceholderScene( - list = secondLastEntry, - detail = lastEntry, - listWeight = listInitialWeight, - detailWeight = 1f - listInitialWeight, - previousEntries = listOf(secondLastEntry), - key = Pair(secondLastEntry.contentKey, lastEntry.contentKey) - ) - } + return buildTwoPaneScene(entries) } + //Only the list is available if (entries.isNotEmpty()) { - val lastEntry = entries.last() - if (lastEntry.metadata[LIST] == true) { - return ListNoPlaceholderScene( - list = lastEntry, - previousEntries = entries.dropLast(1), - key = lastEntry.contentKey - ) - } + return buildAdaptiveListScene(entries) } return null } + + private fun buildTwoPaneScene(entries: List>): Scene? { + val lastEntry = entries.last() + val secondLastEntry = entries[entries.size - 2] + + return if (lastEntry.metadata[DETAIL] == true && + secondLastEntry.metadata[LIST] == true + ) { + buildListDetailScene(secondLastEntry, lastEntry) + } else if (lastEntry.metadata[THIRD_PANEL] == true && + secondLastEntry.metadata[DETAIL] == true && entries.size >= 3 + ) { + val zeroethEntry = entries[entries.size - 3] + buildDetailAndThirdPanelScene(secondLastEntry, lastEntry, zeroethEntry) + } else { + null + } + + } + + private fun buildListDetailScene(firstEntry: NavEntry, secondEntry: NavEntry): Scene { + return ListDetailNoPlaceholderScene( + list = firstEntry, + detail = secondEntry, + listWeight = listInitialWeight, + detailWeight = 1f - listInitialWeight, + previousEntries = listOf(firstEntry), + key = Pair(firstEntry.contentKey, secondEntry.contentKey) + ) + } + + private fun buildDetailAndThirdPanelScene( + firstEntry: NavEntry, + secondEntry: NavEntry, + previousEntry: NavEntry + ): Scene { + return ListDetailNoPlaceholderScene( + list = firstEntry, + detail = secondEntry, + listWeight = listInitialWeight, + detailWeight = 1f - listInitialWeight, + previousEntries = listOf(previousEntry, firstEntry), + key = Pair(firstEntry.contentKey, secondEntry.contentKey) + ) + } + + private fun buildAdaptiveListScene(entries: List>): Scene? { + val lastEntry = entries.last() + if (lastEntry.metadata[LIST] == true) { + return ListNoPlaceholderScene( + list = lastEntry, + previousEntries = entries.dropLast(1), + key = lastEntry.contentKey + ) + } + + return null + } }