Skip to content

[WIP] Add recipe to show Nav2 to Nav3 migration plan #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation2)

implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Nav3Recipes"/>
<activity
android:name=".migration.start.MainActivity"
android:exported="true"
android:theme="@style/Theme.Nav3Recipes"/>
<activity
android:name=".migration.step1.MainActivity"
android:exported="true"
android:theme="@style/Theme.Nav3Recipes"/>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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.migration.start

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable

/**
* Basic Navigation2 example with two screens. This will be the starting point for migration to
* Navigation 3.
*/

@Serializable
private data object RouteA

@Serializable
private data class RouteB(val id: String)

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
Scaffold { paddingValues ->
NavHost(navController = navController, startDestination = RouteA, modifier = Modifier.padding(paddingValues)) {
composable<RouteA> {
Column {
Text("Route A")
Button(onClick = { navController.navigate(route = RouteB(id = "123")) }) {
Text("Go to B")
}
}
}
composable<RouteB> { key ->
Text("Route B: ${key.toRoute<RouteB>().id}")
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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.migration.step1

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable

/**
* Basic Navigation2 example with two screens. This will be the starting point for migration to
* Navigation 3.
*/

@Serializable
private data object RouteA

@Serializable
private data class RouteB(val id: String)

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()

Choose a reason for hiding this comment

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

critical

To ensure proper lifecycle management and prevent memory leaks, a CoroutineScope should be created using rememberCoroutineScope() and passed to Nav3NavigatorSimple. This scope is lifecycle-aware and will be cancelled when the composable leaves the composition. This declaration should precede the nav3Navigator initialization. Don't forget to add import androidx.compose.runtime.rememberCoroutineScope.

Suggested change
val navController = rememberNavController()
val coroutineScope = rememberCoroutineScope()

val nav3Navigator = remember { Nav3NavigatorSimple(navController) }

Choose a reason for hiding this comment

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

critical

The Nav3NavigatorSimple should receive a lifecycle-aware CoroutineScope from its constructor to prevent memory leaks. This ensures that any coroutines launched within Nav3NavigatorSimple are properly cancelled when the MainActivity composable is disposed. This change is paired with the declaration of coroutineScope.

Suggested change
val nav3Navigator = remember { Nav3NavigatorSimple(navController) }
val nav3Navigator = remember { Nav3NavigatorSimple(navController, coroutineScope) }


Scaffold { paddingValues ->
NavDisplay(
backStack = nav3Navigator.backStack,
entryProvider = entryProvider(fallback = { key ->
println("Key $key not handled by entryProvider, using fallback")

Choose a reason for hiding this comment

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

medium

Using println for logging is generally discouraged in Android applications. It's better to use the Log class (e.g., Log.d(TAG, ...)), which allows for filtering by tag and log level. While this might be acceptable for a simple recipe, using Log is a better practice to demonstrate.

Suggested change
println("Key $key not handled by entryProvider, using fallback")
android.util.Log.d("Nav3Migration", "Key $key not handled by entryProvider, using fallback")

NavEntry(key = key) {
NavHost(
navController = navController,
startDestination = RouteA,
modifier = Modifier.padding(paddingValues)
) {
composable<RouteA> {
Column {
Text("Route A")
Button(onClick = { navController.navigate(route = RouteB(id = "123")) }) {
Text("Go to B")
}
}
}
composable<RouteB> { key ->
Text("Route B: ${key.toRoute<RouteB>().id}")
}
}
}
}
) {
// Empty entryProvider
})
}
}
}
}

class Nav3NavigatorSimple(val navController: NavHostController){

val coroutineScope = CoroutineScope(Job())
Comment on lines +97 to +99

Choose a reason for hiding this comment

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

critical

Using CoroutineScope(Job()) creates a scope that is not tied to any lifecycle, which causes memory leaks. The coroutine launched in init will never be cancelled. This class should receive a lifecycle-aware CoroutineScope from its creator (the MainActivity composable in this case) via its constructor. This change is paired with another one in MainActivity where the scope is created and passed.

Suggested change
class Nav3NavigatorSimple(val navController: NavHostController){
val coroutineScope = CoroutineScope(Job())
class Nav3NavigatorSimple(
val navController: NavHostController,
private val coroutineScope: CoroutineScope
) {


// We need a single element to avoid "backStack cannot be empty" error b/430023647
val backStack = mutableStateListOf<Any>(Unit)

init {
coroutineScope.launch {
navController.currentBackStack.collect { nav2BackStack ->
with(backStack) {
if (nav2BackStack.isNotEmpty()){
clear()
val entriesToAdd = nav2BackStack.mapNotNull { entry ->
// Ignore nav graph root entries
if (entry.destination::class.qualifiedName == "androidx.navigation.compose.ComposeNavGraphNavigator.ComposeNavGraph"){
null
} else {
entry
}
Comment on lines +112 to +116

Choose a reason for hiding this comment

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

medium

Checking the qualified name of the destination class is brittle and can break with library updates. A more robust way to identify and ignore the root navigation graph entry is to check if its parent is null.

                            if (entry.destination.parent == null) {
                                // Ignore nav graph root entries
                                null
                            } else {
                                entry
                            }

}
addAll(entriesToAdd)
}
}
}
}
}
}

10 changes: 6 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,22 @@

[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"
kotlinxSerializationCore = "1.9.0"
lifecycleRuntimeKtx = "2.9.1"
lifecycleViewmodel = "1.0.0-SNAPSHOT"
activityCompose = "1.12.0-alpha02"
composeBom = "2025.06.00"
navigation2 = "2.9.1"
navigation3 = "1.0.0-alpha04"
material3 = "1.4.0-alpha15"
nav3Material = "1.0.0-SNAPSHOT"
ksp = "2.2.0-RC2-2.0.1"
ksp = "2.2.0-2.0.2"
hilt = "2.56.2"
hiltNavigationCompose = "1.2.0"

Expand All @@ -51,6 +52,7 @@ androidx-material3-windowsizeclass = { group = "androidx.compose.material3", nam
androidx-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodel" }
androidx-navigation2 = { module = "androidx.navigation:navigation-compose", version.ref = "navigation2" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
Expand Down