Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The androidx.window:window-core dependency is a transitive dependency of androidx.window. You only need to declare the androidx.window dependency. Including redundant dependencies can make dependency management more complex and is generally discouraged.



implementation(libs.kotlinx.serialization.core)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
android:name=".scenes.twopane.TwoPaneActivity"
android:exported="true"
android:theme="@style/Theme.Nav3Recipes"/>
<activity
android:name=".scenes.threepanes.ThreePanesActivity"
android:exported="true"
android:theme="@style/Theme.Nav3Recipes"/>
<activity
android:name=".animations.AnimatedActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.nav3recipes.scenes.threepanes

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND

internal data class DualPaneScene<T : Any>(
override val key: Any,
val firstEntry: NavEntry<T>,
val secondEntry: NavEntry<T>,
override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)

override val content: @Composable () -> Unit = {
Row(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
firstEntry.Content()
}

Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
secondEntry.Content()
}
}
}
}

/**
* A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
* list.
*/
Comment on lines +45 to +47

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The KDoc for DualPaneSceneStrategy is inaccurate. It states that it always creates a "1-entry [Scene]", but it actually creates a DualPaneScene which contains two entries when screen width and back stack depth conditions are met. Please update the documentation to reflect the actual behavior.

Suggested change
* A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
* list.
*/
* A [SceneStrategy] that creates a [DualPaneScene] when the window width is medium or larger
* and there are at least two entries in the back stack.
*/

public class DualPaneSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
@Composable
override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T>? {
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) || entries.size < 2)
return null
return DualPaneScene(
key = entries.last().contentKey,
firstEntry = entries[entries.lastIndex - 1],
secondEntry = entries.last(),
previousEntries = entries.dropLast(2),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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.threepanes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entry
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.ui.SceneStrategy
import androidx.navigation3.ui.SinglePaneSceneStrategy
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentPink
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import com.example.nav3recipes.ui.theme.colors
import kotlinx.serialization.Serializable

/**
* Basic example with two screens that uses the entryProvider DSL and has a persistent back stack.
*/
Comment on lines +50 to +51

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The KDoc for ThreePanesActivity appears to be copied from another example. It mentions "two screens", but this activity demonstrates a three-pane layout. Please update the documentation to be accurate.

Suggested change
* Basic example with two screens that uses the entryProvider DSL and has a persistent back stack.
*/
* Basic example that demonstrates a three-pane layout strategy.
*/


@Serializable
private data object RouteA : NavKey

@Serializable
private data class RouteB(val id: Int) : NavKey {
val color: Color
get() = colors[id % colors.size]
}

@Serializable
private data class RouteC(val id: String) : NavKey

class ThreePanesActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(RouteA)
val info = currentWindowAdaptiveInfo(true).windowSizeClass

val combinedStrategy: SceneStrategy<NavKey> = ThreePaneSceneStrategy<NavKey>(info).then(
DualPaneSceneStrategy<NavKey>(info)
).then(
SinglePaneSceneStrategy()
)


NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategy = combinedStrategy,
entryProvider = entryProvider {
entry<RouteA>(metadata = mapOf(

)) {
MainList { route ->
backStack.add(route)
}
}
entry<RouteB> { key ->
SubList(key) { route ->
backStack.add(route)
}
}
entry<RouteC> { key ->
ContentPink("Route id: ${key.id}")
}
}
)
}
}
}

@Composable
private fun MainList(onItemClicked: (NavKey) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(10) { index ->
val sublistId = index + 1
val route = RouteB(sublistId)
val backgroundColor = route.color
ListItem(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { onItemClicked(route) }),
headlineContent = {
Text(
text = "Conversation $sublistId",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = ListItemDefaults.colors(
containerColor = backgroundColor // Set container color directly
)
)
}
}
}

@Composable
private fun SubList(parentKey: RouteB, onItemClicked: (NavKey) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(10) { index ->
val detailId = "${parentKey.id}${index + 1}"
val route = RouteC(detailId)
val backgroundColor = parentKey.color
ListItem(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor.copy(alpha = (index / 10).toFloat()))
.clickable(onClick = { onItemClicked(route) }),
headlineContent = {
Text(
text = "Conversation $detailId",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = ListItemDefaults.colors(
containerColor = backgroundColor // Set container color directly
)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.example.nav3recipes.scenes.threepanes

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
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
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_LARGE_LOWER_BOUND
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND is unused in this file and can be removed to improve code cleanliness.


internal data class ThreePaneScene<T : Any>(
override val key: Any,
override val entries: List<NavEntry<T>>,
override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {

override val content: @Composable () -> Unit = {
val (firstEntry, secondEntry, thirdEntry) = computeEntriesAndEmptyState()
Row(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
firstEntry.Content()
}

val weight = if(thirdEntry != null) 1f else 2f
if(secondEntry != null) {
Column(
modifier = Modifier
.fillMaxHeight()
.weight(weight)
) {
secondEntry.Content()
}

if(thirdEntry != null) {
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
thirdEntry.Content()
}
}
} else {
Column(
modifier = Modifier
.fillMaxHeight()
.weight(2f)
) {
Text("Empty state")
}
}
}
}

private fun computeEntriesAndEmptyState() : Triple<NavEntry<T>, NavEntry<T>?, NavEntry<T>?> {
if(entries.size >= 3) {
return Triple(entries[entries.lastIndex - 2], entries[entries.lastIndex - 1], entries.last())
} else if(entries.size == 2) {
return Triple(entries[entries.lastIndex - 1], entries.last(), null)
} else {
return Triple(entries.last(), null, null)
}
}
Comment on lines +65 to +73

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This if-else if-else chain can be replaced with a when expression, which is more idiomatic and generally more readable in Kotlin.

    private fun computeEntriesAndEmptyState() : Triple<NavEntry<T>, NavEntry<T>?, NavEntry<T>?> {
        return when {
            entries.size >= 3 -> Triple(entries[entries.lastIndex - 2], entries[entries.lastIndex - 1], entries.last())
            entries.size == 2 -> Triple(entries.first(), entries.last(), null)
            else -> Triple(entries.first(), null, null)
        }
    }


}

/**
* A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
* list.
*/
Comment on lines +78 to +80

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The KDoc for ThreePaneSceneStrategy is inaccurate and seems to be copied from another class. It should describe that this strategy creates a ThreePaneScene for large-width windows.

 * A [SceneStrategy] that creates a [ThreePaneScene] when the window width is large. The
 * [ThreePaneScene] can display one, two, or three panes depending on the back stack depth.
 */

public class ThreePaneSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
@Composable
override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T>? {
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_LARGE_LOWER_BOUND))
return null
return ThreePaneScene(
key = entries.last().contentKey,
entries = entries,
previousEntries = entries.dropLast(3),
)
}
}

/*
@Composable
internal fun <T : Any> SceneStrategy<T>.calculateSceneWithSinglePaneFallback(
entries: List<NavEntry<T>>,
onBack: (count: Int) -> Unit,
): Scene<T> =
calculateScene(entries, onBack) ?: ThreePaneSceneStrategy<T>().calculateScene(entries, onBack)
*/
Comment on lines +94 to +101

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This commented-out code should be removed before merging to keep the codebase clean and avoid confusion.

23 changes: 13 additions & 10 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,24 @@

[versions]
agp = "8.10.1"
kotlin = "2.2.0-RC2"
kotlinSerialization = "2.1.21"
kotlin = "2.2.0"
kotlinSerialization = "2.2.0"
coreKtx = "1.16.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
kotlinxSerializationCore = "1.8.1"
lifecycleRuntimeKtx = "2.9.1"
kotlinxSerializationCore = "1.9.0"
lifecycleRuntimeKtx = "2.9.2"
lifecycleViewmodel = "1.0.0-SNAPSHOT"
activityCompose = "1.12.0-alpha02"
composeBom = "2025.06.00"
navigation3 = "1.0.0-alpha04"
material3 = "1.4.0-alpha15"
activityCompose = "1.12.0-alpha04"
composeBom = "2025.07.00"
navigation3 = "1.0.0-alpha05"
material3 = "1.4.0-alpha18"
nav3Material = "1.0.0-SNAPSHOT"
ksp = "2.2.0-RC2-2.0.1"
hilt = "2.56.2"
ksp = "2.2.0-2.0.2"
hilt = "2.57"
hiltNavigationCompose = "1.2.0"
window = "1.5.0-beta01"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
Expand Down Expand Up @@ -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" }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The androidx-window-core library is a transitive dependency of androidx-window. It's good practice to only declare direct dependencies in your Gradle files. Since androidx.window.core is not needed directly, you can remove this line. Remember to also remove the corresponding implementation from app/build.gradle.kts.


[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down