diff --git a/README.md b/README.md index 71222c2..3c1a5eb 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,34 @@ use its APIs to implement common navigation use cases. ## Recipes These are the recipes and what they demonstrate. -**Basic API examples** +### Basic API examples - **[Basic](app/src/main/java/com/example/nav3recipes/basic)**: Shows most basic API usage. - **[Saveable back stack](app/src/main/java/com/example/nav3recipes/basicsaveable)**: As above, with a persistent back stack. - **[Entry provider DSL](app/src/main/java/com/example/nav3recipes/basicdsl)**: As above, using the entryProvider DSL. -**Layouts and animations** -- **[Material adaptive](app/src/main/java/com/example/nav3recipes/scenes/materiallistdetail)**: Shows how to use a Material list-detail layout. +### Layouts and animations - **[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. -**Common use cases** +### 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) +- **[List-Detail](app/src/main/java/com/example/nav3recipes/material/listdetail)**: Shows how to use a Material adaptive list-detail layout. +- **[Supporting Pane](app/src/main/java/com/example/nav3recipes/material/supportingpane)**: Shows how to use a Material adaptive supporting pane layout. + +### Common use cases - **[Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: A common navigation toolbar where each item in the toolbar navigates to a top level destination. - **[Conditional navigation](app/src/main/java/com/example/nav3recipes/conditional)**: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. -**Architecture** +### Architecture - **[Modularized navigation code](app/src/main/java/com/example/nav3recipes/modular/hilt)**: Demonstrates how to decouple navigation code into separate modules (uses Dagger/Hilt for DI). -**Passing navigation arguments to ViewModels** -- **[Basic ViewModel](app/src/main/java/com/example/nav3recipes/passingarguments/basicviewmodels)**: Navigation arguments are passed to a ViewModel constructed using `viewModel()` -- **[Hilt injected ViewModel](app/src/main/java/com/example/nav3recipes/passingarguments/injectedviewmodels)**: Navigation arguments are passed to a ViewModel constructed using `hiltViewModel()` +### Passing navigation arguments to ViewModels +- **[Basic ViewModel](app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic)**: Navigation arguments are passed to a ViewModel constructed using `viewModel()` +- **[Hilt injected ViewModel](app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt)**: Navigation arguments are passed to a ViewModel constructed using `hiltViewModel()` +- **[Koin injected ViewModel](app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin)**: Navigation arguments are passed to a ViewModel constructed using `koinViewModel()` -**Planned** +### Planned - **Deeplinks**: Create and handle deeplinks to specific destinations - **Android XR**: Custom navigation and layout behavior for Android XR - **Returning a result from a destination**: Return a result to a previous destination diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1b2051..bcc9d73 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,9 +80,11 @@ 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) + implementation(libs.koin.compose.viewmodel) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) @@ -91,4 +93,5 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + testImplementation(kotlin("test")) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/nav3recipes/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/nav3recipes/ExampleInstrumentedTest.kt deleted file mode 100644 index 5121c2a..0000000 --- a/app/src/androidTest/java/com/example/nav3recipes/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.nav3recipes", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/nav3recipes/MigrationActivityNavigationTest.kt b/app/src/androidTest/java/com/example/nav3recipes/MigrationActivityNavigationTest.kt new file mode 100644 index 0000000..ce57b61 --- /dev/null +++ b/app/src/androidTest/java/com/example/nav3recipes/MigrationActivityNavigationTest.kt @@ -0,0 +1,177 @@ +package com.example.nav3recipes + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso +import com.example.nav3recipes.migration.start.StartMigrationActivity +import com.example.nav3recipes.migration.step2.Step2MigrationActivity +import com.example.nav3recipes.migration.step3.Step3MigrationActivity +import com.example.nav3recipes.migration.step4.Step4MigrationActivity +import com.example.nav3recipes.migration.step5.Step5MigrationActivity +import com.example.nav3recipes.migration.step6.Step6MigrationActivity +import com.example.nav3recipes.migration.step7.Step7MigrationActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +/** + * Instrumented navigation tests for each of the migration steps. + */ +@RunWith(Parameterized::class) +class MigrationActivityNavigationTest(activityClass: Class) { + + @get:Rule(order = 0) + val composeTestRule = createAndroidComposeRule(activityClass) + + companion object { + @JvmStatic + @Parameters(name = "{0}") + fun data(): Collection> { + return listOf( + arrayOf(StartMigrationActivity::class.java), + arrayOf(Step2MigrationActivity::class.java), + arrayOf(Step3MigrationActivity::class.java), + arrayOf(Step4MigrationActivity::class.java), + arrayOf(Step5MigrationActivity::class.java), + arrayOf(Step6MigrationActivity::class.java), + arrayOf(Step7MigrationActivity::class.java) + ) + } + } + + @Test + fun firstScreen_isA() { + composeTestRule.apply { + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + onNodeWithText("Route A title").assertExists() + } + } + + @Test + fun navigateToB_selectsB() { + composeTestRule.apply { + onNode(hasText("Route B") and isSelectable()).performClick() + onNode(hasText("Route B") and isSelectable()).assertIsSelected() + onNodeWithText("Route B title").assertExists() + } + } + + @Test + fun navigateToA1_keepsASelected() { + composeTestRule.apply { + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + onNodeWithText("Route A title").assertExists() + onNodeWithText("Go to A1").performClick() + onNodeWithText("Route A1 title").assertExists() + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + } + } + + @Test + fun navigateAtoBtoC_selectsCAndShowsContent() { + composeTestRule.apply { + onNode(hasText("Route B") and isSelectable()).performClick() + onNode(hasText("Route B") and isSelectable()).assertIsSelected() + onNodeWithText("Route B title").assertExists() + + onNode(hasText("Route C") and isSelectable()).performClick() + onNode(hasText("Route C") and isSelectable()).assertIsSelected() + onNodeWithText("Route C title").assertExists() + } + } + + @Test + fun navigateAtoB_pressBack_showsA() { + composeTestRule.apply { + onNode(hasText("Route B") and isSelectable()).performClick() + onNode(hasText("Route B") and isSelectable()).assertIsSelected() + onNodeWithText("Route B title").assertExists() + + Espresso.pressBack() + + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + onNodeWithText("Route A title").assertExists() + } + } + + @Test + fun navigateAtoA1_pressBack_showsAContent() { + composeTestRule.apply { + onNodeWithText("Go to A1").performClick() + onNodeWithText("Route A1 title").assertExists() + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + + Espresso.pressBack() + + onNodeWithText("Route A title").assertExists() + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + } + } + + @Test + fun navigateAtoBtoC_thenBack_showsA() { + composeTestRule.apply { + onNode(hasText("Route B") and isSelectable()).performClick() + onNode(hasText("Route B") and isSelectable()).assertIsSelected() + onNodeWithText("Route B title").assertExists() + + onNode(hasText("Route C") and isSelectable()).performClick() + onNode(hasText("Route C") and isSelectable()).assertIsSelected() + onNodeWithText("Route C title").assertExists() + + Espresso.pressBack() + + onNode(hasText("Route A") and isSelectable()).assertIsSelected() + onNodeWithText("Route A title").assertExists() + onNodeWithText("Route B title").assertDoesNotExist() + } + } + + /** + * TODO: Investigate why these dialog tests sometimes fail. + */ + @Test + fun navigateToDialogD_onA_showsDialogContentAndDismisses() { + composeTestRule.apply { + + onNodeWithText("Open dialog D").performClick() + onNodeWithText("Route D title (dialog)").assertExists() + Espresso.pressBack() + onNodeWithText("Route A title").assertExists() + } + } + + @Test + fun navigateToDialogD_onB_showsDialogContentAndDismisses() { + composeTestRule.apply { + + onNode(hasText("Route B") and isSelectable()).performClick() + + onNodeWithText("Open dialog D").performClick() + onNodeWithText("Route D title (dialog)").assertExists() + Espresso.pressBack() + onNodeWithText("Route B title").assertExists() + } + } + + + @Test + fun navigateToDialogD_onC_showsDialogContentAndDismisses() { + composeTestRule.apply { + + onNode(hasText("Route C") and isSelectable()).performClick() + + onNodeWithText("Open dialog D").performClick() + onNodeWithText("Route D title (dialog)").assertExists() + Espresso.pressBack() + onNodeWithText("Route C title").assertExists() + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4040165..fa8dc3f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,7 +66,15 @@ android:exported="true" android:theme="@style/Theme.Nav3Recipes"/> + + + + + + + + + + diff --git a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt index 127ad53..8d60280 100644 --- a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt @@ -29,13 +29,16 @@ import com.example.nav3recipes.animations.AnimatedActivity import com.example.nav3recipes.basic.BasicActivity import com.example.nav3recipes.basicdsl.BasicDslActivity import com.example.nav3recipes.basicsaveable.BasicSaveableActivity +import com.example.nav3recipes.bottomsheet.BottomSheetActivity import com.example.nav3recipes.commonui.CommonUiActivity import com.example.nav3recipes.conditional.ConditionalActivity import com.example.nav3recipes.dialog.DialogActivity import com.example.nav3recipes.modular.hilt.ModularActivity -import com.example.nav3recipes.passingarguments.basicviewmodels.BasicViewModelsActivity -import com.example.nav3recipes.passingarguments.injectedviewmodels.InjectedViewModelsActivity -import com.example.nav3recipes.scenes.materiallistdetail.MaterialListDetailActivity +import com.example.nav3recipes.passingarguments.viewmodels.basic.BasicViewModelsActivity +import com.example.nav3recipes.passingarguments.viewmodels.hilt.HiltViewModelsActivity +import com.example.nav3recipes.passingarguments.viewmodels.koin.KoinViewModelsActivity +import com.example.nav3recipes.material.listdetail.MaterialListDetailActivity +import com.example.nav3recipes.material.supportingpane.MaterialSupportingPaneActivity import com.example.nav3recipes.scenes.twopane.TwoPaneActivity import com.example.nav3recipes.ui.setEdgeToEdgeConfig @@ -56,8 +59,11 @@ private val recipes = listOf( Recipe("Basic Saveable", BasicSaveableActivity::class.java), Heading("Layouts and animations"), + Recipe("Bottom Sheet", BottomSheetActivity::class.java), Recipe("Material list-detail layout", MaterialListDetailActivity::class.java), + Recipe("Material supporting-pane layout", MaterialSupportingPaneActivity::class.java), Recipe("Dialog", DialogActivity::class.java), + Recipe("Material list-detail layout", MaterialListDetailActivity::class.java), Recipe("Two pane layout (custom scene)", TwoPaneActivity::class.java), Recipe("Animations", AnimatedActivity::class.java), @@ -68,9 +74,10 @@ private val recipes = listOf( Heading("Architecture"), Recipe("Modular Navigation", ModularActivity::class.java), - Heading("Passing navigation arguments"), - Recipe("Argument passing to basic ViewModel", BasicViewModelsActivity::class.java), - Recipe("Argument passing to injected ViewModel", InjectedViewModelsActivity::class.java), + Heading("Passing navigation arguments using ViewModels"), + Recipe("Basic", BasicViewModelsActivity::class.java), + Recipe("Using Hilt", HiltViewModelsActivity::class.java), + Recipe("Using Koin", KoinViewModelsActivity::class.java), ) class RecipePickerActivity : ComponentActivity() { diff --git a/app/src/main/java/com/example/nav3recipes/animations/AnimatedActivity.kt b/app/src/main/java/com/example/nav3recipes/animations/AnimatedActivity.kt index b906976..9fdac6d 100644 --- a/app/src/main/java/com/example/nav3recipes/animations/AnimatedActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/animations/AnimatedActivity.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.material3.Button import androidx.compose.material3.Text 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 diff --git a/app/src/main/java/com/example/nav3recipes/basicdsl/BasicDslActivity.kt b/app/src/main/java/com/example/nav3recipes/basicdsl/BasicDslActivity.kt index bad77ca..3b25100 100644 --- a/app/src/main/java/com/example/nav3recipes/basicdsl/BasicDslActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/basicdsl/BasicDslActivity.kt @@ -22,7 +22,6 @@ import androidx.activity.compose.setContent import androidx.compose.material3.Button import androidx.compose.material3.Text 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 diff --git a/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetActivity.kt b/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetActivity.kt new file mode 100644 index 0000000..46348e3 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetActivity.kt @@ -0,0 +1,93 @@ +/* + * 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.bottomsheet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +/** + * This recipe demonstrates how to create a bottom sheet. It does this by: + * + * - Adding the `BottomSheetSceneStrategy` to the list of strategies used by `NavDisplay`. + * - Adding `BottomSheetSceneStrategy.bottomSheet()` to a `NavEntry`'s metadata to indicate that it + * is a bottom sheet. In this case it is applied to the `NavEntry` for `RouteB`. + * + * See also https://developer.android.com/guide/navigation/navigation-3/custom-layouts + */ + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class BottomSheetActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + val bottomSheetStrategy = remember { BottomSheetSceneStrategy() } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = bottomSheetStrategy, + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + Button(onClick = { + backStack.add(RouteB("123")) + }) { + Text("Click to open bottom sheet") + } + } + } + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { key -> + ContentBlue( + title = "Route id: ${key.id}", + modifier = Modifier.clip( + shape = RoundedCornerShape(16.dp) + ) + ) + } + } + ) + } + } +} diff --git a/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetSceneStrategy.kt b/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetSceneStrategy.kt new file mode 100644 index 0000000..f72b24d --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/bottomsheet/BottomSheetSceneStrategy.kt @@ -0,0 +1,79 @@ +package com.example.nav3recipes.bottomsheet + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) +internal class BottomSheetScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: (count: Int) -> Unit, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + ModalBottomSheet( + onDismissRequest = { onBack(1) }, + properties = modalBottomSheetProperties, + ) { + entry.Content() + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetSceneStrategy : SceneStrategy { + + @Composable + override fun calculateScene( + entries: List>, + onBack: (Int) -> Unit + ): Scene? { + val lastEntry = entries.lastOrNull() + val bottomSheetProperties = lastEntry?.metadata?.get(BOTTOM_SHEET_KEY) as? ModalBottomSheetProperties + return bottomSheetProperties?.let { properties -> + @Suppress("UNCHECKED_CAST") + BottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + modalBottomSheetProperties = properties, + onBack = onBack + ) + } + } + + companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [ModalBottomSheet]. + * + * @param modalBottomSheetProperties properties that should be passed to the containing + * [ModalBottomSheet]. + */ + @OptIn(ExperimentalMaterial3Api::class) + fun bottomSheet( + modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties() + ): Map = mapOf(BOTTOM_SHEET_KEY to modalBottomSheetProperties) + + internal const val BOTTOM_SHEET_KEY = "bottomsheet" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index 2b1cb0e..bd3d014 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.vector.ImageVector -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue diff --git a/app/src/main/java/com/example/nav3recipes/conditional/ConditionalActivity.kt b/app/src/main/java/com/example/nav3recipes/conditional/ConditionalActivity.kt index ccc15e1..18f9670 100644 --- a/app/src/main/java/com/example/nav3recipes/conditional/ConditionalActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/conditional/ConditionalActivity.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue diff --git a/app/src/main/java/com/example/nav3recipes/dialog/DialogActivity.kt b/app/src/main/java/com/example/nav3recipes/dialog/DialogActivity.kt index 4d69ed8..3923d25 100644 --- a/app/src/main/java/com/example/nav3recipes/dialog/DialogActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/dialog/DialogActivity.kt @@ -28,10 +28,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.DialogSceneStrategy +import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen diff --git a/app/src/main/java/com/example/nav3recipes/scenes/materiallistdetail/MaterialListDetailActivity.kt b/app/src/main/java/com/example/nav3recipes/material/listdetail/MaterialListDetailActivity.kt similarity index 98% rename from app/src/main/java/com/example/nav3recipes/scenes/materiallistdetail/MaterialListDetailActivity.kt rename to app/src/main/java/com/example/nav3recipes/material/listdetail/MaterialListDetailActivity.kt index 7b6e2ff..370d2f6 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/materiallistdetail/MaterialListDetailActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/material/listdetail/MaterialListDetailActivity.kt @@ -1,4 +1,4 @@ -package com.example.nav3recipes.scenes.materiallistdetail +package com.example.nav3recipes.material.listdetail /* * Copyright 2025 The Android Open Source Project @@ -31,7 +31,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp 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 @@ -68,6 +67,7 @@ class MaterialListDetailActivity : ComponentActivity() { val backStack = rememberNavBackStack(ConversationList) // Override the defaults so that there isn't a horizontal space between the panes. + // See b/418201867 val windowAdaptiveInfo = currentWindowAdaptiveInfo() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) diff --git a/app/src/main/java/com/example/nav3recipes/material/supportingpane/MaterialSupportingPaneActivity.kt b/app/src/main/java/com/example/nav3recipes/material/supportingpane/MaterialSupportingPaneActivity.kt new file mode 100644 index 0000000..0241621 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/material/supportingpane/MaterialSupportingPaneActivity.kt @@ -0,0 +1,122 @@ +package com.example.nav3recipes.material.supportingpane + +/* + * 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. + */ + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +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 kotlinx.serialization.Serializable + +/** + * This example uses the Material SupportingPaneSceneStrategy to create an adaptive scene. It has three + * destinations: Content, RelatedContent and Profile. When the window width allows it, + * the content for these destinations will be shown in a two pane layout. + */ +@Serializable +private object MainVideo : NavKey + +@Serializable +private data object RelatedVideos : NavKey + +@Serializable +private data object Profile : NavKey + +class MaterialSupportingPaneActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + val backStack = rememberNavBackStack(MainVideo) + + // Override the defaults so that there isn't a horizontal or vertical space between the panes. + // See b/444438086 + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) + } + + // Override the defaults so that the supporting pane can be dismissed by pressing back. + // See b/445826749 + val supportingPaneStrategy = rememberSupportingPaneSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, + directive = directive + ) + + NavDisplay( + backStack = backStack, + onBack = { numKeysToRemove -> repeat(numKeysToRemove) { backStack.removeLastOrNull() } }, + sceneStrategy = supportingPaneStrategy, + entryProvider = entryProvider { + entry( + metadata = SupportingPaneSceneStrategy.mainPane() + ) { + ContentRed("Video content") { + Button(onClick = { + backStack.add(RelatedVideos) + }) { + Text("View related videos") + } + } + } + entry( + metadata = SupportingPaneSceneStrategy.supportingPane() + ) { + ContentBlue("Related videos") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { + backStack.add(Profile) + }) { + Text("View profile") + } + } + } + } + entry( + metadata = SupportingPaneSceneStrategy.extraPane() + ) { + ContentGreen("Profile") + } + } + ) + } + } +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/start/StartMigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/start/StartMigrationActivity.kt new file mode 100644 index 0000000..7043ae5 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/start/StartMigrationActivity.kt @@ -0,0 +1,255 @@ +/* + * 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.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation.toRoute +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +/** + * Basic Navigation2 example with the following navigation graph: + * + * A -> A, A1, E + * B -> B, B1, E + * C -> C, E + * D + * + * - The starting destination (or home screen) is A. + * - A, B and C are top level destinations that appear in a navigation bar. + * - D is a dialog destination. + * - E is a shared destination that can appear under any of the top level destinations. + * - Navigating to a top level destination pops all other top level destinations off the stack, + * except for the start destination. + * - Navigating back from the start destination exits the app. + * + * This will be the starting point for migration to Navigation 3. + * + * @see `MigrationActivityNavigationTest` for instrumented tests that verify this behavior. + */ + +// Feature module A +@Serializable private data object BaseRouteA +@Serializable private data object RouteA +@Serializable private data object RouteA1 + +// Feature module B +@Serializable private data object BaseRouteB +@Serializable private data object RouteB +@Serializable private data class RouteB1(val id: String) + +// Feature module C +@Serializable private data object BaseRouteC +@Serializable private data object RouteC + +// Common UI modules +@Serializable private data object RouteD +@Serializable private data object RouteE + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +class NavBarItem( + val icon: ImageVector, + val description: String +) + +class StartMigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + NavHost( + navController = navController, + startDestination = BaseRouteA, + modifier = Modifier.padding(paddingValues) + ) { + featureASection( + onSubRouteClick = { navController.navigate(RouteA1) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureBSection( + onDetailClick = { id -> navController.navigate(RouteB1(id)) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureCSection( + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + dialog { key -> + Text(modifier = Modifier.background(Color.White), text = "Route D title (dialog)") + } + } + } + } + } +} + +// Feature module A +private fun NavGraphBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteA) { + composable { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentPink("Route A1 title") } + composable { ContentBlue("Route E title") } + } +} + +// Feature module B +private fun NavGraphBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + navigation(startDestination = RouteB) { + composable { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { key -> + ContentPurple("Route B1 title. ID: ${key.toRoute().id}") + } + composable { ContentBlue("Route E title") } + } +} + +// Feature module C +private fun NavGraphBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteC) { + composable { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentBlue("Route E title") } + } +} + + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step2/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step2/Navigator.kt new file mode 100644 index 0000000..50817e1 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step2/Navigator.kt @@ -0,0 +1,201 @@ +package com.example.nav3recipes.migration.step2 + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Navigator that mirrors `NavController`'s back stack + */ +@SuppressLint("RestrictedApi") +class Navigator( + coroutineScope: CoroutineScope, + private val navController: NavHostController, + private val startRoute: Any = Unit, + private val canTopLevelRoutesExistTogether: Boolean = false, + private val shouldPrintDebugInfo: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private lateinit var topLevelStacks : MutableMap> + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes: MutableMap = mutableMapOf() + + //val coroutineScope = CoroutineScope(Job()) + + init { + inititalizeTopLevelStacks() + coroutineScope.launch { + navController.currentBackStack.collect { nav2BackStack -> + inititalizeTopLevelStacks() + navlog("Top level stacks reset, parsing Nav2 back stack $nav2BackStack") + printTopLevelStacks() + + nav2BackStack.forEach { entry -> + val destination = entry.destination + + if (destination.navigatorName == "composable" || destination.navigatorName == "dialog"){ + val route = + // Add migrated routes here + if (false) { + } else { + // Non migrated top level route + entry + } + add(route) + } else { + navlog("Ignoring $entry") + } + } + printTopLevelStacks() + updateBackStack() + } + } + } + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + if (entry is NavBackStackEntry){ + message.append("Unmigrated route: ${entry.destination.route}, ") + } else { + message.append("Migrated route: $entry, ") + } + } + message.append("]\n") + return message.toString() + } + + private fun addTopLevel(route: Any) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun inititalizeTopLevelStacks() { + topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + topLevelRoute = startRoute + } + + private fun add(route: Any) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + navController.navigate(route, navOptions) + + // Uncomment the code below and delete the line as per migration guide + /* + add(route) + updateBackStack() + */ + } + + /** + * Go back to the previous route. + */ + fun goBack() { + navController.popBackStack() + + // Uncomment the code below and delete the line as per migration guide + /* + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + */ + } +} + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step2/Step2MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step2/Step2MigrationActivity.kt new file mode 100644 index 0000000..c9d57a9 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step2/Step2MigrationActivity.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.migration.step2 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation.toRoute +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +private data object BaseRouteA + +@Serializable +private data object RouteA + +@Serializable +private data object RouteA1 + +@Serializable +private data object BaseRouteB + +@Serializable +private data object RouteB + +@Serializable +private data class RouteB1(val id: String) + +@Serializable +private data object BaseRouteC + +@Serializable +private data object RouteC + +@Serializable +private data object RouteD + +@Serializable +private data object RouteE + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + +class Step2MigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator = remember { Navigator(coroutineScope, navController) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = + currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + NavHost( + navController = navController, + startDestination = BaseRouteA, + ) { + featureASection( + onSubRouteClick = { navController.navigate(RouteA1) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureBSection( + onDetailClick = { id -> navController.navigate(RouteB1(id)) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureCSection( + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + dialog { key -> + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + } + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider( + fallback = { key -> + NavEntry(key = key) {} + } + ) { + // No nav entries added yet. + } + ) + } + } + } + } +} + +private fun NavGraphBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteA) { + composable { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentPink("Route A1 title") } + composable { ContentBlue("Route E title") } + } +} + +private fun NavGraphBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + navigation(startDestination = RouteB) { + composable { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { key -> + ContentPurple("Route B1 title. ID: ${key.toRoute().id}") + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavGraphBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteC) { + composable { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step3/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step3/Navigator.kt new file mode 100644 index 0000000..c40ba69 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step3/Navigator.kt @@ -0,0 +1,200 @@ +package com.example.nav3recipes.migration.step3 + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import com.example.nav3recipes.migration.step4.RouteB +import com.example.nav3recipes.migration.step4.RouteB1 +import com.example.nav3recipes.migration.step4.RouteE +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Navigator that mirrors `NavController`'s back stack + */ +@SuppressLint("RestrictedApi") +internal class Navigator( + coroutineScope: CoroutineScope, + private val navController: NavHostController, + private val startRoute: Any = Unit, + private val canTopLevelRoutesExistTogether: Boolean = false, + private val shouldPrintDebugInfo: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private lateinit var topLevelStacks : MutableMap> + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes: MutableMap = mutableMapOf() + + init { + inititalizeTopLevelStacks() + coroutineScope.launch { + navController.currentBackStack.collect { nav2BackStack -> + inititalizeTopLevelStacks() + navlog("Top level stacks reset, parsing Nav2 back stack $nav2BackStack") + printTopLevelStacks() + + nav2BackStack.forEach { entry -> + val destination = entry.destination + + if (destination.navigatorName == "composable" || destination.navigatorName == "dialog"){ + val route = + // Add migrated routes here + if (false) { + } else { + // Non migrated top level route + entry + } + add(route) + } else { + navlog("Ignoring $entry") + } + } + printTopLevelStacks() + updateBackStack() + } + } + } + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + if (entry is NavBackStackEntry){ + message.append("Unmigrated route: ${entry.destination.route}, ") + } else { + message.append("Migrated route: $entry, ") + } + } + message.append("]\n") + return message.toString() + } + + private fun addTopLevel(route: Any) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun inititalizeTopLevelStacks() { + topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + topLevelRoute = startRoute + } + + private fun add(route: Any) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + navController.navigate(route, navOptions) + + // TODO: add instruction on when to uncomment this and remove the line above + /* + add(route) + updateBackStack() + */ + } + + /** + * Go back to the previous route. + */ + fun goBack() { + navController.popBackStack() + + // TODO: add instruction on when to uncomment this and remove the line above + /* + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + */ + } +} + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step3/Step3MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step3/Step3MigrationActivity.kt new file mode 100644 index 0000000..599fd59 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step3/Step3MigrationActivity.kt @@ -0,0 +1,259 @@ +/* + * 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.step3 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation.toRoute +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +private data object BaseRouteA +@Serializable +private data object RouteA +@Serializable +private data object RouteA1 + +@Serializable +private data object BaseRouteB +@Serializable +private data object RouteB : Route.TopLevel +@Serializable +private data class RouteB1(val id: String) + +@Serializable +private data object BaseRouteC +@Serializable +private data object RouteC +@Serializable +private data object RouteD +@Serializable +private data object RouteE : Route.Shared + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + +class Step3MigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator = remember { Navigator(coroutineScope, navController) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = + currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + NavHost( + navController = navController, + startDestination = BaseRouteA + ) { + featureASection( + onSubRouteClick = { navController.navigate(RouteA1) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureBSection( + onDetailClick = { id -> navController.navigate(RouteB1(id)) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + featureCSection( + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + dialog { key -> + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + } + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider( + fallback = { key -> + NavEntry(key = key) {} + } + ) { + // No nav entries added yet. + } + ) + } + } + } + } +} + +private fun NavGraphBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteA) { + composable { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentPink("Route A1 title") } + composable { ContentBlue("Route E title") } + } +} + +private fun NavGraphBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + navigation(startDestination = RouteB) { + composable { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { key -> + ContentPurple("Route B1 title. ID: ${key.toRoute().id}") + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavGraphBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteC) { + composable { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step4/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step4/Navigator.kt new file mode 100644 index 0000000..6ab878a --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step4/Navigator.kt @@ -0,0 +1,202 @@ +package com.example.nav3recipes.migration.step4 + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Navigator that mirrors `NavController`'s back stack + */ +@SuppressLint("RestrictedApi") +internal class Navigator( + coroutineScope: CoroutineScope, + private val navController: NavHostController, + private val startRoute: Any = Unit, + private val canTopLevelRoutesExistTogether: Boolean = false, + private val shouldPrintDebugInfo: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private lateinit var topLevelStacks : MutableMap> + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes: MutableMap = mutableMapOf() + + init { + inititalizeTopLevelStacks() + coroutineScope.launch { + navController.currentBackStack.collect { nav2BackStack -> + inititalizeTopLevelStacks() + navlog("Top level stacks reset, parsing Nav2 back stack $nav2BackStack") + printTopLevelStacks() + + nav2BackStack.forEach { entry -> + val destination = entry.destination + + if (destination.navigatorName == "composable" || destination.navigatorName == "dialog"){ + val route = + if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else { + // Non migrated top level route + entry + } + add(route) + } else { + navlog("Ignoring $entry") + } + } + printTopLevelStacks() + updateBackStack() + } + } + } + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + if (entry is NavBackStackEntry){ + message.append("Unmigrated route: ${entry.destination.route}, ") + } else { + message.append("Migrated route: $entry, ") + } + } + message.append("]\n") + return message.toString() + } + + private fun addTopLevel(route: Any) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun inititalizeTopLevelStacks() { + topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + topLevelRoute = startRoute + } + + private fun add(route: Any) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + navController.navigate(route, navOptions) + + // TODO: add instruction on when to uncomment this and remove the line above + /* + add(route) + updateBackStack() + */ + } + + /** + * Go back to the previous route. + */ + fun goBack() { + navController.popBackStack() + + // TODO: add instruction on when to uncomment this and remove the line above + /* + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + */ + } + +} + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step4/Step4MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step4/Step4MigrationActivity.kt new file mode 100644 index 0000000..3b8f4cf --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step4/Step4MigrationActivity.kt @@ -0,0 +1,272 @@ +/* + * 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.step4 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +data object BaseRouteA + +@Serializable +data object RouteA + +@Serializable +data object RouteA1 + +@Serializable +data object BaseRouteB + +@Serializable +data object RouteB : Route.TopLevel + +@Serializable +data class RouteB1(val id: String) + +@Serializable +data object BaseRouteC + +@Serializable +data object RouteC + +@Serializable +data object RouteD + +@Serializable +data object RouteE + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + + +class Step4MigrationActivity : ComponentActivity() { + + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator = + remember { Navigator(coroutineScope, navController, shouldPrintDebugInfo = true) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = + currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + NavHost( + navController = navController, + startDestination = BaseRouteA + ) { + featureASection( + onSubRouteClick = { navController.navigate(RouteA1) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + navigation(startDestination = RouteB) { + composable {} + composable {} + composable {} + } + featureCSection( + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + dialog { key -> + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + } + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider( + fallback = { key -> + NavEntry(key = key) {} + } + ) { + featureBSection( + onDetailClick = { id -> navController.navigate(RouteB1(id)) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + } + ) + } + } + } + } +} + +private fun NavGraphBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteA) { + composable { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentPink("Route A1 title") } + composable { ContentBlue("Route E title") } + } +} + +private fun EntryProviderBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") + } + entry { ContentBlue("Route E title") } +} + +private fun NavGraphBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteC) { + composable { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step5/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step5/Navigator.kt new file mode 100644 index 0000000..6913714 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step5/Navigator.kt @@ -0,0 +1,202 @@ +package com.example.nav3recipes.migration.step5 + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Navigator that mirrors `NavController`'s back stack + */ +@SuppressLint("RestrictedApi") +internal class Navigator( + coroutineScope: CoroutineScope, + private val navController: NavHostController, + private val startRoute: Any = Unit, + private val canTopLevelRoutesExistTogether: Boolean = false, + private val shouldPrintDebugInfo: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private lateinit var topLevelStacks : MutableMap> + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes: MutableMap = mutableMapOf() + + init { + inititalizeTopLevelStacks() + coroutineScope.launch { + navController.currentBackStack.collect { nav2BackStack -> + inititalizeTopLevelStacks() + navlog("Top level stacks reset, parsing Nav2 back stack $nav2BackStack") + printTopLevelStacks() + + nav2BackStack.forEach { entry -> + val destination = entry.destination + + if (destination.navigatorName == "composable" || destination.navigatorName == "dialog"){ + val route = + if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else { + // Non migrated top level route + entry + } + add(route) + } else { + navlog("Ignoring $entry") + } + } + printTopLevelStacks() + updateBackStack() + } + } + } + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + if (entry is NavBackStackEntry){ + message.append("Unmigrated route: ${entry.destination.route}, ") + } else { + message.append("Migrated route: $entry, ") + } + } + message.append("]\n") + return message.toString() + } + + private fun addTopLevel(route: Any) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun inititalizeTopLevelStacks() { + topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + topLevelRoute = startRoute + } + + private fun add(route: Any) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + navController.navigate(route, navOptions) + + // TODO: add instruction on when to uncomment this and remove the line above + /* + add(route) + updateBackStack() + */ + } + + /** + * Go back to the previous route. + */ + fun goBack() { + navController.popBackStack() + + // TODO: add instruction on when to uncomment this and remove the line above + /* + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + */ + } + +} + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step5/Step5MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step5/Step5MigrationActivity.kt new file mode 100644 index 0000000..58ef24b --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step5/Step5MigrationActivity.kt @@ -0,0 +1,262 @@ +/* + * 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.step5 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +data object BaseRouteA +@Serializable +data object RouteA +@Serializable +data object RouteA1 + +@Serializable +data object BaseRouteB +@Serializable +data object RouteB : Route.TopLevel +@Serializable +data class RouteB1(val id: String) + +@Serializable +data object BaseRouteC +@Serializable +data object RouteC +@Serializable +data object RouteD +@Serializable +data object RouteE + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + + +class Step5MigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator = remember { Navigator(coroutineScope, navController, shouldPrintDebugInfo = true) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = + currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + NavHost( + navController = navController, + startDestination = BaseRouteA + ) { + featureASection( + onSubRouteClick = { navController.navigate(RouteA1) }, + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + navigation(startDestination = RouteB) { + composable {} + composable {} + composable {} + } + featureCSection( + onDialogClick = { navController.navigate(RouteD) }, + onOtherClick = { navController.navigate(RouteE) } + ) + dialog { key -> + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + } + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider( + fallback = { key -> + NavEntry(key = key) {} + } + ) { + featureBSection( + onDetailClick = { id -> navigator.navigate(RouteB1(id)) }, + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + } + ) + } + } + } + } +} + +private fun NavGraphBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteA) { + composable { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentPink("Route A1 title") } + composable { ContentBlue("Route E title") } + } +} + +private fun EntryProviderBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") + } + entry { ContentBlue("Route E title") } + } + +private fun NavGraphBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + navigation(startDestination = RouteC) { + composable { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + composable { ContentBlue("Route E title") } + } +} + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step6/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step6/Navigator.kt new file mode 100644 index 0000000..59ed462 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step6/Navigator.kt @@ -0,0 +1,210 @@ +package com.example.nav3recipes.migration.step6 + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Navigator that mirrors `NavController`'s back stack + */ +@SuppressLint("RestrictedApi") +internal class Navigator( + coroutineScope: CoroutineScope, + private val navController: NavHostController, + private val startRoute: Any = Unit, + private val canTopLevelRoutesExistTogether: Boolean = false, + private val shouldPrintDebugInfo: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private lateinit var topLevelStacks : MutableMap> + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes: MutableMap = mutableMapOf() + + init { + inititalizeTopLevelStacks() + coroutineScope.launch { + navController.currentBackStack.collect { nav2BackStack -> + inititalizeTopLevelStacks() + navlog("Top level stacks reset, parsing Nav2 back stack $nav2BackStack") + printTopLevelStacks() + + nav2BackStack.forEach { entry -> + val destination = entry.destination + + if (destination.navigatorName == "composable" || destination.navigatorName == "dialog"){ + val route = + if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else { + // Non migrated top level route + entry + } + add(route) + } else { + navlog("Ignoring $entry") + } + } + printTopLevelStacks() + updateBackStack() + } + } + } + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + if (entry is NavBackStackEntry){ + message.append("Unmigrated route: ${entry.destination.route}, ") + } else { + message.append("Migrated route: $entry, ") + } + } + message.append("]\n") + return message.toString() + } + + private fun addTopLevel(route: Any) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun inititalizeTopLevelStacks() { + topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + topLevelRoute = startRoute + } + + private fun add(route: Any) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + navController.navigate(route, navOptions) + + // Uncomment the code below and delete the line as per migration guide + /* + add(route) + updateBackStack() + */ + } + + /** + * Go back to the previous route. + */ + fun goBack() { + navController.popBackStack() + + // Uncomment the code below and delete the line as per migration guide + /* + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + */ + } + +} + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step6/Step6MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step6/Step6MigrationActivity.kt new file mode 100644 index 0000000..fa1ec1c --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step6/Step6MigrationActivity.kt @@ -0,0 +1,272 @@ +/* + * 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.step6 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +data object BaseRouteA +@Serializable +data object RouteA : Route.TopLevel +@Serializable +data object RouteA1 + +@Serializable +data object BaseRouteB +@Serializable +data object RouteB : Route.TopLevel +@Serializable +data class RouteB1(val id: String) + +@Serializable +data object BaseRouteC +@Serializable +data object RouteC : Route.TopLevel +@Serializable +data object RouteD +@Serializable +data object RouteE : Route.Shared + +private val TOP_LEVEL_ROUTES = mapOf( + BaseRouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + BaseRouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + BaseRouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + + +class Step6MigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() + val navigator = remember { Navigator(coroutineScope, navController) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = + currentBackStackEntry?.destination.isRouteInHierarchy(key::class) + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(key, navOptions { + popUpTo(route = RouteA) + }) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + // Base Layer: Legacy NavHost is always in the composition tree. + NavHost( + navController = navController, + startDestination = BaseRouteA + ) { + // All routes are now rendered by NavDisplay, so these are all empty. + navigation(startDestination = RouteA) { + composable {} + composable {} + composable {} + } + navigation(startDestination = RouteB) { + composable {} + composable {} + composable {} + } + navigation(startDestination = RouteC) { + composable {} + composable {} + } + dialog {} + } + + // Overlay Layer: NavDisplay for all screens. + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + sceneStrategy = remember { DialogSceneStrategy() }, + entryProvider = entryProvider( + fallback = { key -> + // Should ideally not be called if all routes are migrated. + NavEntry(key = key) {} + } + ) { + featureASection( + onSubRouteClick = { navigator.navigate(RouteA1) }, + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + featureBSection( + onDetailClick = { id -> navigator.navigate(RouteB1(id)) }, + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + featureCSection( + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + entry(metadata = DialogSceneStrategy.dialog()) { + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + } + ) + } + } + } + } +} + +private fun EntryProviderBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + entry { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { ContentPink("Route A1 title") } + entry { ContentBlue("Route E title") } +} + +private fun EntryProviderBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") + } +} + +private fun EntryProviderBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + entry { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } +} + +private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/migration/step7/Navigator.kt b/app/src/main/java/com/example/nav3recipes/migration/step7/Navigator.kt new file mode 100644 index 0000000..a75c7c8 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step7/Navigator.kt @@ -0,0 +1,250 @@ +package com.example.nav3recipes.migration.step7 + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import androidx.savedstate.write +import kotlinx.serialization.Serializable +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator + +@SuppressLint("RestrictedApi") +class Navigator ( + private var startRoute: Route, + private var canTopLevelRoutesExistTogether: Boolean = false, + private var shouldPrintDebugInfo: Boolean = false, +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private val topLevelStacks = mutableMapOf(startRoute to mutableListOf(startRoute)) + + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes : MutableMap = mutableMapOf() + + private fun updateBackStack() { + backStack.apply { + clear() + val entries = topLevelStacks.flatMap { it.value } + addAll(entries) + } + printBackStack() + } + + fun navlog(message: String){ + if (shouldPrintDebugInfo){ + println(message) + } + } + + private fun printBackStack() { + navlog("Back stack: ${backStack.getDebugString()}") + } + + private fun printTopLevelStacks() { + + navlog("Top level stacks: ") + topLevelStacks.forEach { topLevelStack -> + navlog(" ${topLevelStack.key} => ${topLevelStack.value.getDebugString()}") + } + } + + private fun List.getDebugString() : String { + val message = StringBuilder("[") + forEach { entry -> + message.append("Route: $entry, ") + } + message.append("]") + return message.toString() + } + + private fun addTopLevel(route: Route) { + if (route == startRoute) { + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + navlog("Added top level route $route") + } + topLevelRoute = route + } + + private fun clearAllExceptStartStack() { + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + private fun add(route: Route) { + navlog("Attempting to add $route") + if (route is Route.TopLevel) { + navlog("$route is a top level route") + addTopLevel(route) + } else { + if (route is Route.Shared) { + navlog("$route is a shared route") + // If the key is already in a stack, remove it + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) + } + sharedRoutes[route] = topLevelRoute + } else { + navlog("$route is a normal route") + } + val hasBeenAdded = topLevelStacks[topLevelRoute]?.add(route) ?: false + navlog("Added $route to $topLevelRoute stack: $hasBeenAdded") + } + } + + /** + * Navigate to the given route. + */ + fun navigate(route: Route) { + add(route) + updateBackStack() + } + + /** + * Go back to the previous route. + */ + fun goBack() { + if (backStack.size <= 1) { + return + } + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + } + + companion object { + private const val KEY_START_ROUTE = "start_route" + private const val KEY_CAN_TOP_LEVEL_ROUTES_EXIST_TOGETHER = "can_top_level_routes_exist_together" + private const val KEY_SHOULD_PRINT_DEBUG_INFO = "should_print_debug_info" + private const val KEY_TOP_LEVEL_ROUTE = "top_level_route" + private const val KEY_TOP_LEVEL_STACK_IDS = "top_level_stack_ids" + private const val KEY_TOP_LEVEL_STACK_KEY_PREFIX = "top_level_stack_key_" + private const val KEY_TOP_LEVEL_STACK_VALUES_PREFIX = "top_level_stack_values_" + private const val KEY_SHARED_ROUTES_KEYS = "shared_routes_keys" + private const val KEY_SHARED_ROUTES_VALUES = "shared_routes_values" + + val Saver = Saver( + save = { navigator -> + val savedState = SavedState() + savedState.write { + + putSavedState(KEY_START_ROUTE, encodeToSavedState(navigator.startRoute)) + putBoolean(KEY_CAN_TOP_LEVEL_ROUTES_EXIST_TOGETHER, navigator.canTopLevelRoutesExistTogether) + putBoolean(KEY_SHOULD_PRINT_DEBUG_INFO, navigator.shouldPrintDebugInfo) + putSavedState(KEY_TOP_LEVEL_ROUTE, encodeToSavedState(navigator.topLevelRoute)) + + // Create lists for each top level stack. Example: + // top_level_stack_ids = [1, 2, 3] + // top_level_stack_key_1 = [encodedStateA] + // top_level_stack_values_1 = [encodedStateA, encodedStateA1] + // top_level_stack_key_2 = ... + + var id = 0 + val ids = mutableListOf() + + for ((key, stackValues) in navigator.topLevelStacks){ + putSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id", encodeToSavedState(key)) + putSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id", stackValues.map { encodeToSavedState(it) }) + ids.add(id) + id++ + } + + putIntList(KEY_TOP_LEVEL_STACK_IDS, ids) + + val sharedRouteKeys = navigator.sharedRoutes.keys.toList() + val sharedRouteValues = navigator.sharedRoutes.values.toList() + putSavedStateList(KEY_SHARED_ROUTES_KEYS, sharedRouteKeys.map { encodeToSavedState(it) }) + putSavedStateList(KEY_SHARED_ROUTES_VALUES, sharedRouteValues.map { encodeToSavedState(it) }) + } + savedState + }, + restore = { savedState -> + savedState.read { + val restoredStartRoute = decodeFromSavedState(getSavedState(KEY_START_ROUTE)) + val restoredCanTopLevelRoutesExistTogether = getBoolean(KEY_CAN_TOP_LEVEL_ROUTES_EXIST_TOGETHER) + val restoredShouldPrintDebugInfo = getBoolean(KEY_SHOULD_PRINT_DEBUG_INFO) + + val navigator = Navigator( + startRoute = restoredStartRoute, + canTopLevelRoutesExistTogether = restoredCanTopLevelRoutesExistTogether, + shouldPrintDebugInfo = restoredShouldPrintDebugInfo + ) + + navigator.topLevelRoute = decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) + + val ids = getIntList(KEY_TOP_LEVEL_STACK_IDS) + for (id in ids){ + // get the key and the value list + val key : Route = decodeFromSavedState(getSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id")) + val stackValues = getSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id") + .map { decodeFromSavedState(it)} + navigator.topLevelStacks[key] = stackValues.toMutableList() + } + + val encodedSharedRouteKeys = getSavedStateListOrNull(KEY_SHARED_ROUTES_KEYS) + val encodedSharedRouteValues = getSavedStateListOrNull(KEY_SHARED_ROUTES_VALUES) + + if (encodedSharedRouteKeys != null && + encodedSharedRouteValues != null && + encodedSharedRouteKeys.size == encodedSharedRouteValues.size) { + val restoredKeys = encodedSharedRouteKeys.map { decodeFromSavedState(it) } + val restoredValues = encodedSharedRouteValues.map { decodeFromSavedState(it) } + navigator.sharedRoutes.clear() + for (i in restoredKeys.indices) { + navigator.sharedRoutes[restoredKeys[i]] = restoredValues[i] + } + } + navigator.updateBackStack() + navigator + } + } + ) + } +} + +@Composable +fun rememberNavigator( + startRoute: Route, + canTopLevelRoutesExistTogether: Boolean = false, + shouldPrintDebugInfo: Boolean = false +) = rememberSaveable(saver = Navigator.Saver){ + Navigator( + startRoute = startRoute, + canTopLevelRoutesExistTogether = canTopLevelRoutesExistTogether, + shouldPrintDebugInfo = shouldPrintDebugInfo + ) +} + +@Serializable +sealed class Route { + sealed class TopLevel : Route() + sealed class Shared : Route() +} diff --git a/app/src/main/java/com/example/nav3recipes/migration/step7/Step7MigrationActivity.kt b/app/src/main/java/com/example/nav3recipes/migration/step7/Step7MigrationActivity.kt new file mode 100644 index 0000000..7cac64a --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/migration/step7/Step7MigrationActivity.kt @@ -0,0 +1,213 @@ +/* + * 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.step7 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + + +@Serializable +data object RouteA : Route.TopLevel() +@Serializable +data object RouteA1 : Route() + +@Serializable +data object RouteB : Route.TopLevel() +@Serializable +data class RouteB1(val id: String) : Route() + +@Serializable +data object RouteC : Route.TopLevel() +@Serializable +data object RouteD : Route() +@Serializable +data object RouteE : Route.Shared() + +private val TOP_LEVEL_ROUTES = mapOf( + RouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + RouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + RouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + + +class Step7MigrationActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + val navigator = rememberNavigator(startRoute = RouteA, shouldPrintDebugInfo = true) + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = key == navigator.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { + navigator.navigate(key) + }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) + + { paddingValues -> + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + sceneStrategy = remember { DialogSceneStrategy() }, + entryProvider = entryProvider { + featureASection( + onSubRouteClick = { navigator.navigate(RouteA1) }, + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + featureBSection( + onDetailClick = { id -> navigator.navigate(RouteB1(id)) }, + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + featureCSection( + onDialogClick = { navigator.navigate(RouteD) }, + onOtherClick = { navigator.navigate(RouteE) } + ) + entry(metadata = DialogSceneStrategy.dialog()) { + Text( + modifier = Modifier.background(Color.White), + text = "Route D title (dialog)" + ) + } + }, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} + +private fun EntryProviderBuilder.featureASection( + onSubRouteClick: () -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + entry { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { ContentPink("Route A1 title") } + entry { ContentBlue("Route E title") } +} + +private fun EntryProviderBuilder.featureBSection( + onDetailClick: (id: String) -> Unit, + onDialogClick: () -> Unit, + onOtherClick: () -> Unit +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") + } +} + +private fun EntryProviderBuilder.featureCSection( + onDialogClick: () -> Unit, + onOtherClick: () -> Unit, +) { + entry { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onDialogClick) { + Text("Open dialog D") + } + Button(onClick = onOtherClick) { + Text("Go to E") + } + } + } + } +} diff --git a/app/src/main/java/com/example/nav3recipes/modular/hilt/ConversationModule.kt b/app/src/main/java/com/example/nav3recipes/modular/hilt/ConversationModule.kt index c99cd75..25dcc98 100644 --- a/app/src/main/java/com/example/nav3recipes/modular/hilt/ConversationModule.kt +++ b/app/src/main/java/com/example/nav3recipes/modular/hilt/ConversationModule.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.navigation3.runtime.entry import com.example.nav3recipes.ui.theme.colors import dagger.Module import dagger.Provides diff --git a/app/src/main/java/com/example/nav3recipes/modular/hilt/ProfileModule.kt b/app/src/main/java/com/example/nav3recipes/modular/hilt/ProfileModule.kt index 4d399a3..6c8b7f3 100644 --- a/app/src/main/java/com/example/nav3recipes/modular/hilt/ProfileModule.kt +++ b/app/src/main/java/com/example/nav3recipes/modular/hilt/ProfileModule.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation3.runtime.entry import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/example/nav3recipes/passingarguments/basicviewmodels/BasicViewModelsActivity.kt b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic/BasicViewModelsActivity.kt similarity index 96% rename from app/src/main/java/com/example/nav3recipes/passingarguments/basicviewmodels/BasicViewModelsActivity.kt rename to app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic/BasicViewModelsActivity.kt index adf29bb..dd1463f 100644 --- a/app/src/main/java/com/example/nav3recipes/passingarguments/basicviewmodels/BasicViewModelsActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic/BasicViewModelsActivity.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.nav3recipes.passingarguments.basicviewmodels +package com.example.nav3recipes.passingarguments.viewmodels.basic import android.os.Bundle import androidx.activity.ComponentActivity @@ -29,11 +29,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.ui.setEdgeToEdgeConfig diff --git a/app/src/main/java/com/example/nav3recipes/passingarguments/injectedviewmodels/InjectedViewModelsActivity.kt b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt/HiltViewModelsActivity.kt similarity index 93% rename from app/src/main/java/com/example/nav3recipes/passingarguments/injectedviewmodels/InjectedViewModelsActivity.kt rename to app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt/HiltViewModelsActivity.kt index a3f959d..093d593 100644 --- a/app/src/main/java/com/example/nav3recipes/passingarguments/injectedviewmodels/InjectedViewModelsActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt/HiltViewModelsActivity.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.nav3recipes.passingarguments.injectedviewmodels +package com.example.nav3recipes.passingarguments.viewmodels.hilt import android.os.Bundle import androidx.activity.ComponentActivity @@ -25,17 +25,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen -import com.example.nav3recipes.passingarguments.basicviewmodels.RouteB +import com.example.nav3recipes.passingarguments.viewmodels.basic.RouteB import com.example.nav3recipes.ui.setEdgeToEdgeConfig import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -54,7 +53,7 @@ data object RouteA data class RouteB(val id: String) @AndroidEntryPoint -class InjectedViewModelsActivity : ComponentActivity() { +class HiltViewModelsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() diff --git a/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin/KoinViewModelsActivity.kt b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin/KoinViewModelsActivity.kt new file mode 100644 index 0000000..3374677 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin/KoinViewModelsActivity.kt @@ -0,0 +1,102 @@ +package com.example.nav3recipes.passingarguments.viewmodels.koin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import org.koin.android.ext.koin.androidContext +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.context.GlobalContext +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module + +/** + * Passing navigation arguments to a Koin injected ViewModel + * + * - ViewModelStoreNavEntryDecorator ensures that ViewModels are scoped to the NavEntry + */ + +data object RouteA +data class RouteB(val id: String) + +class KoinViewModelsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + + // The startKoin block should be placed in Application.onCreate. + if (GlobalContext.getOrNull() == null) { + GlobalContext.startKoin { + androidContext(this@KoinViewModelsActivity) + modules( + module { + viewModelOf(::RouteBViewModel) + } + ) + } + } + + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = remember { mutableStateListOf(RouteA) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + + // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why) + // we also need to add the default `NavEntryDecorator`s as well. These provide + // extra information to the entry's content to enable it to display correctly + // and save its state. + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + LazyColumn { + items(10) { i -> + Button(onClick = { + backStack.add(RouteB("$i")) + }) { + Text("$i") + } + } + } + } + } + entry { key -> + val viewModel = koinViewModel { + parametersOf(key) + } + ScreenB(viewModel = viewModel) + } + } + ) + } + } +} + +@Composable +fun ScreenB(viewModel: RouteBViewModel) { + ContentBlue("Route id: ${viewModel.navKey.id} ") +} + +class RouteBViewModel(val navKey: RouteB) : ViewModel() \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt index c29ae00..db6202c 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt @@ -31,18 +31,17 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.navEntryDecorator import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.example.nav3recipes.content.ContentBase import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.content.ContentRed @@ -105,7 +104,7 @@ class TwoPaneActivity : ComponentActivity() { val backStack = rememberNavBackStack(Home) - val twoPaneStrategy = remember { TwoPaneSceneStrategy() } + val twoPaneStrategy = remember { TwoPaneSceneStrategy() } SharedTransitionLayout { CompositionLocalProvider(localNavSharedTransitionScope provides this) { @@ -159,7 +158,7 @@ class TwoPaneActivity : ComponentActivity() { } } - private fun SnapshotStateList.addProductRoute(productId: Int) { + private fun NavBackStack.addProductRoute(productId: Int) { val productRoute = Product(productId) // Avoid adding the same product route to the back stack twice. diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt index c214625..03b8b11 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt @@ -7,11 +7,10 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.ui.Scene -import androidx.navigation3.ui.SceneStrategy +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND diff --git a/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt b/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt deleted file mode 100644 index 32b7a81..0000000 --- a/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt new file mode 100644 index 0000000..d24e832 --- /dev/null +++ b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt @@ -0,0 +1,117 @@ +package com.example.nav3recipes.navigator.basic + +import org.junit.Test +import kotlin.test.assertEquals + +class NavigatorTest { + + private data object A : RouteV2(isTopLevel = true) + + private data object A1 : RouteV2() + private data object B : RouteV2(isTopLevel = true) + private data object B1 : RouteV2() + private data object C : RouteV2(isTopLevel = true) + private data object D : RouteV2(isShared = true) + + @Test + fun backStackContainsOnlyStartRoute(){ + val navigator = Navigator(startRoute = A) + assertEquals(listOf(A), navigator.backStack) + } + + @Test + fun navigatingToTopLevelRoute_addsRouteToTopOfStack(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + assertEquals(listOf(A, B), navigator.backStack) + } + + @Test + fun navigatingToChildRoute_addsToCurrentTopLevelStack() { + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + navigator.navigate(B1) + assertEquals(listOf(A, B, B1), navigator.backStack) + } + + @Test + fun navigatingToNewTopLevelRoute_popsOtherStacksExceptStartStack() { + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) // [A, A1] + navigator.navigate(C) // [A, A1, C] + navigator.navigate(B) // [A, A1, B] + val expected = listOf(A, A1, B) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToSharedRoute_whenItsAlreadyOnStack_movesItToNewStack() { + val navigator = Navigator(startRoute = A) + navigator.navigate(D) // [A, D] + navigator.navigate(C) // [A, D, C] + navigator.navigate(D) // [A, C, D] + val expected = listOf(A, C, D) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToStartRoute_whenOtherRoutesAreOnStack_popsAllOtherRoutes() { + val navigator = Navigator(startRoute = A) + navigator.navigate(B) // [A, B] + navigator.navigate(C) // [A, B, C] + navigator.navigate(A) // [A] + val expected : List = listOf(A) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToStartRoute_whenItHasSubRoutes_retainsSubRoutes() { + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) // [A, A1] + navigator.navigate(B) // [A, A1, B] + navigator.navigate(A) // [A, A1] + val expected : List = listOf(A, A1) + assertEquals(expected, navigator.backStack) + } + + @Test + fun repeatedlyNavigatingToTopLevelRoute_retainsSubRoutes(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + navigator.navigate(B1) + navigator.navigate(B) + + val expected = listOf(A, B, B1) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToTopLevelRoute_whenTopLevelRoutesCanExistTogether_retainsSubRoutes(){ + val navigator = Navigator(startRoute = A, canTopLevelRoutesExistTogether = true) + navigator.navigate(A) + navigator.navigate(A1) + navigator.navigate(B) + navigator.navigate(B1) + navigator.navigate(C) + navigator.navigate(B) + + val expected = listOf(A, A1, C, B, B1) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingBack_isChronological(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) + navigator.navigate(B) + navigator.navigate(B1) + assertEquals(listOf(A, A1, B, B1), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A, A1, B), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A, A1), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A), navigator.backStack) + + } +} \ No newline at end of file diff --git a/docs/MigratingFromNavigation2.md b/docs/MigratingFromNavigation2.md new file mode 100644 index 0000000..735c974 --- /dev/null +++ b/docs/MigratingFromNavigation2.md @@ -0,0 +1,535 @@ +# Navigation 2 to 3 migration guide +**IMPORTANT:** This document is a 🚧work in progress🚧 and as such care should be taken when implementing the steps in this guide +in your own app. We welcome your feedback! + +## Overview + +This is intended to be a general guide to [Navigation 2](https://developer.android.com/guide/navigation) to [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) migration which can be applied to any project. It attempts to keep the project in a working state during migration (build succeeds, navigation tests pass) and does this by maintaining interoperability between Nav2 and Nav3. + +The guide assumes that the project is [modularized](https://developer.android.com/topic/modularization), and the suggested approach allows feature modules to adopt Nav3 on an incremental basis. Once all feature modules have migrated, the Nav2 code can be safely removed. If the codebase is not modularized, the steps for specific modules should be applied to the main `app` module. + +### Features + +This guide covers the migration of the following Nav2 features: + +- Nested navigation graphs - single level of nesting only +- Shared destinations - ones that can appear in different navigation graphs +- Dialog destinations + +The following features are not yet supported: + +- More than one level of nested navigation +- [Custom destination types](https://developer.android.com/guide/navigation/design/kotlin-dsl#custom) +- Deeplinks + +### Prerequisites + +- Familiarity with [navigation terminology](https://developer.android.com/guide/navigation). +- Destinations are Composable functions. Nav3 is designed exclusively for Compose. Fragment destinations can be wrapped with [`AndroidFragment`](https://developer.android.com/reference/kotlin/androidx/fragment/compose/package-summary#AndroidFragment(androidx.compose.ui.Modifier,androidx.fragment.compose.FragmentState,android.os.Bundle,kotlin.Function1)) for interoperability with Compose. +- Routes are strongly typed. If you are using string-based routes, [migrate to type-safe routes](https://medium.com/androiddevelopers/type-safe-navigation-for-compose-105325a97657) first ([example](https://github.com/android/nowinandroid/pull/1413)). +- Optional but highly recommended: test coverage that verifies existing navigation behavior. These will ensure that during migration, navigation behavior is not changed. See [here for an example of navigation tests](https://github.com/android/nav3-recipes/blob/main/app/src/androidTest/java/com/example/nav3recipes/MigrationActivityNavigationTest.kt). +- Your app must have a [minSdk](https://developer.android.com/guide/topics/manifest/uses-sdk-element#min) of 23 or above. + +### Step-by-step code examples + +A [migration recipe](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/migration) exists to accompany this guide. It starts with an activity containing only Nav2 code. The end state of each migration step is represented by another activity. Instrumented tests verify the navigation behavior in every step. + +To use the migration recipe as a guide: + +- Clone the [nav3-recipes repository](https://github.com/android/nav3-recipes), load it into Android Studio and switch to the Android view in the project explorer +- Expand the `com.example.nav3recipes.migration` package +- Open `start.StartMigrationActivity`. Familiarise yourself with the navigation structure and behavior defined in this activity. For simplicity, the recipe codebase is not modularized, however, it is structured in a way that should make it clear where the module boundaries would be in a real app. This is the starting point for migration. + +![Screenshot of Android Studio showing the starting point for migration](images/migration/start_migration.png) + +- Open `MigrationActivityNavigationTest` which is under the `androidTest` source set. This file contains the instrumented tests that verify the navigation behavior. +- Run the tests. Note that the tests are run on every migration step activity. + +Tips for working with the migration recipe: + +- It can be useful to see the differences between migration steps. To do this, highlight both files in Android Studio project explorer then right click and choose "Compare files". The first file to be selected is the one that will appear in the left window pane. + +![Screenshot of Android Studio showing how to compare files](images/migration/compare_files.png) + +- To run the migration tests only on a single activity, uncomment the other activities in the `data` function inside `MigrationActivityNavigationTest`. +- The migration steps introduce a `Navigator` class. Setting its `shouldPrintDebugInfo` parameter to `true` will output lots of debug information to Logcat. + +## Step 1. Add the Nav3 dependencies + +The latest dependencies can be found here: [https://developer.android.com/guide/navigation/navigation-3/get-started](https://developer.android.com/guide/navigation/navigation-3/get-started) + +- Update `lib.versions.toml` to include the Nav3 dependencies. Use the [latest version from here](https://developer.android.com/jetpack/androidx/releases/navigation3). + +``` +androidxNavigation3 = "1.0.0-alpha10" +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } +``` + +### 1.1 Create a common navigation module + +- If you don't have one already, create a :`core:navigation` module +- Add the Nav3 runtime as a dependency using `api(libs.androidx.navigation3.runtime). This` allows modules depending on :core:navigation `to` use the Nav3 runtime API. +- Add the Nav2 dependencies. These will be removed once migration is complete. + +Example `build.gradle.kts`: + +``` +dependencies { + api(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation2) +} +``` + +**Note**: Nav3 has two libraries: `runtime` and `ui`. Usually only the `app` module needs to depend on `navigation3.ui` which is why it isn't included in the :`core:navigation` dependencies above. + +### 1.2 Update main app module + +- Update the `app` module to depend on :`core:navigation` and on `androidx.navigation3.ui` +- Update compileSdk to 36 or above +- Update minSdk to 23 or above +- Update AGP to 8.9.3 or above + +## Step 2. Create a back stack and use it with NavDisplay + +### 2.1 Add the Navigator class + +Important: A fundamental difference between Nav2 and Nav3 is that **you own the back stack**. This means much of the logic and state that was previously managed by Nav2, must now be managed by you. This gives you greater flexibility and control, but also more responsibility. + +To aid with migration, a class which provides and manages a back stack named `Navigator` is provided for you. It is not part of the Nav3 library and it does not provide all the features of Nav2. Instead it is intended to be an assistant during migration, and after to be a starting point for you to implement your own navigation behavior and logic. + +Copy the [`Navigator`](https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/migration/step2/Navigator.kt) to the :`core:navigation` module. This class contains `backStack: SnapshotStateList` that can be used with `NavDisplay.It` will mirror `NavController`'s back stack ensuring that Nav2's state remains the source of truth throughout migration. After the migration is complete, the NavController mirroring code will be removed. + +### 2.2 Make the Navigator available everywhere that NavController is + +Goal: The `Navigator` class is available everywhere that `NavController` is used. + +- Create the `Navigator` immediately after `NavController` is created + +Example: + +``` +val navController = rememberNavController() +val navigator = remember { Navigator(navController) } +``` + +- Do a project-wide search for "NavController" and "NavHostController" +- Update any classes or methods that accept a `NavController` or `NavHostController` to also accept `Navigator` as a parameter + +### 2.3 Add a `NavDisplay` on top of `NavHost` + +Goal: `NavDisplay` is displayed transparently on top of your existing `NavHost` + +The idea here is to let `NavHost` render all the legacy routes and `NavDisplay` render migrated routes. When `NavDisplay` encounters a legacy route it will render nothing, allowing the original `NavHost` to render the route instead. + +- Wrap your existing `NavHost` with a `Box` +- Add a `NavDisplay` inside the `Box` under the `NavHost` +- Pass your `Navigator`'s back stack to the `NavDisplay` +- Set `NavDisplay.onBack` to call `navigator.goBack()` +- Create an `entryProvider` with a `fallback` lambda that has no composable content causing the existing `NavHost` to be displayed + +Example: + +``` +Box { + NavHost(...) + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider( + fallback = { key -> + NavEntry(key = key) {} + } + ) { + // No nav entries added yet. + } + ) +} +``` + +## Step 3. [Single feature] Migrate routes + +Goal: Routes are moved into their own :`feature:api` module and their properties are modelled outside of `NavHost` + +### 3.1 Split feature module into api and impl + +Choose **a single feature module** that does not contain the start destination for your app. The feature module containing the start destination will be migrated last. + +Create the `api` module: + +- Create a new feature module named :`:api` +- Move the routes into it +- Apply the KotlinX Serialization plugin to the module by updating the `build.gradle.kts` + +Update the :`core:navigation` module: + +- Add a dependency on :`:api` - this allows `Navigator` to access the feature's routes. It is not good practice for :`core` modules to depend on :`feature` modules, however, this is necessary during migration. This dependency will be removed once migration is complete. + +Create the `impl` module: + +- Move the remaining contents of :`` into :`:impl` +- Add the following dependencies: + - :`:api` so it has access to the routes + - :`core:navigation` so it has access to the Nav3 APIs and the `Navigator` class + +Update :`app` dependencies: + +- Update the :`app` module to depend on both :`:api` and :`:impl`. + +### 3.2 Model nested navigation graphs + +Skip this section if you don't use nested navigation graphs. + +#### 3.2.1 Nested graph migration overview + +In Nav2, you can define nested navigation graphs using the `navigation` builder function inside `NavHost. NavController` provides a back stack for each nested graph. This allows you to have several top level destinations, each with sub-destinations. You can also define shared destinations - ones that are accessible from more than one navigation graph - by duplicating the destination in multiple graphs. + +For example, the following code defines two nested navigation graphs, each containing a shared destination defined by `RouteE`. + +``` +@Serializable private data object BaseRouteA +@Serializable private data object RouteA +@Serializable private data object RouteA1 +@Serializable private data object BaseRouteB +@Serializable private data object RouteB +@Serializable private data object RouteB1 +@Serializable private data object RouteE + +NavHost(startDestination = BaseRouteA){ + navigation(startDestination = RouteA) { + composable { ContentRed("Route A title") } + composable { ContentRed("Route A1 title") } + composable { SharedScreen() } + } + navigation(startDestination = RouteB) { + composable { ContentRed("Route B title") } + composable { ContentRed("Route B1 title") } + composable { SharedScreen() } + } +} +``` + +In Nav3, how you model relationships between routes is up to you. In this migration guide, `NavHost` will be removed so it's important that the properties and relationships defined here are captured elsewhere. + +One possible way of modelling these properties (though by no means the only way), is to define marker interfaces for the top level and shared routes. + +``` +@Serializable private data object RouteA : Route.TopLevel +@Serializable private data object RouteA1 +@Serializable private data object RouteB : Route.TopLevel +@Serializable private data object RouteB1 +@Serializable private data object RouteE : Route.Shared + +sealed interface Route { + interface TopLevel : Route + interface Shared : Route +} +``` + +Once modelled, the nested navigation and shared destination behavior can be implemented as follows inside a class that provides a back stack, such as the provided `Navigator`. + +#### For nested navigation + +- Each top level route has its own back stack. +- The first element in that back stack is the top level route - this approach means we no longer need the `BaseRoute` objects to identify each nested navigation graph, the first element is sufficient. +- The current top level route is tracked and sub routes are added to its back stack - there is no explicit definition of parent-child relationships between routes +- When the top level route changes, other top level stacks can be retained or discarded. +- The top level stacks can be flattened into a single list which is observed by `NavDisplay`. + +#### For shared routes + +- When navigating to a route that implements `Route.Shared`, check whether it's already on a top level stack: + - If so, move it to the current top level stack + - If not, add it to the current top level stack + +#### Example + +Taking the code example from above. The starting route is A. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

User action

Current top level route

Top level stacks

Back stack

Notes

Open app

A

A => [A]

A

Tap on A1

A

A => [A, A1]

A, A1

Tap on B

B

A => [A, A1]

B => [B]

A, A1, B

Tap on B1

B

A => [A, A1]

B => [B, B1]

A, A1, B, B1

Tap on E

B

A => [A, A1]

B => [B, B1, E]

A, A1, B, B1, E

Tap on A

A

A => [A, A1]

A, A1

We make the decision to pop all B's routes from the stack, this is similar to using popUpTo(A) when navigating using Nav2

Tap on E

A

A => [A, A1, E]

A, A1, E

+ +##### Steps + +Review the provided `Navigator` class to ensure that it can model your app's current navigation behavior. In particular: + +- Review the [`add`](https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/migration/step2/Navigator.kt#L142) +- Note that `popUpTo` in the [`navigate`](https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/migration/step7/Navigator.kt#L121) will be ignored when switching to Nav3 in the final step. There is no equivalent to `popUpTo` in Nav3 because you control the back stack. The supplied `Navigator` class does, however, include logic to pop all top level stacks up to the starting stack when navigating to a new top level route. This behavior can be toggled using `canTopLevelRoutesExistTogether`. + +#### 3.2.2 Update routes to implement marker interfaces + +Steps: + +- Update each top level route so that it implements the `Route.TopLevel` interface provided by `Navigator.kt` +- Update each shared route so that it implements the `Route.Shared` interface provided by `Navigator.kt` + +## Step 4. [Single feature] Move destinations from NavHost to entryProvider + +Goal: When navigating to a migrated route, it is provided using `NavDisplay's entryProvider`. + +### 4.1 Move composable content from NavHost into entryProvider + +Continue only with the feature module being migrated in the previous step. + +#### 4.1.1 Move directly defined destinations, such as composable + +For each destination inside `NavHost`, do the following based the destination type: + +- `navigation` - do nothing +- `composable` - Copy the function into `entryProvider` and rename `composable` to `entry`, retaining the type parameter. Remove the composable content from the old `composable` leaving it empty i.e., `composable`{}. +- `dialog` - Same as composable but add metadata to the entry as follows: entry(metadata `= DialogSceneStrategy.dialog()`) + - If you haven't already, add `DialogSceneStrategy` to `NavDisplay's sceneStrategy` parameter. +- [`bottomSheet`](https://developer.android.com/reference/kotlin/androidx/compose/material/navigation/package-summary#(androidx.navigation.NavGraphBuilder).bottomSheet(kotlin.String,kotlin.collections.List,kotlin.collections.List,kotlin.Function2)) - [Follow the bottom sheet recipe here](https://github.com/android/nav3-recipes/pull/67). This essentially the same as the instructions for `dialog` except that `BottomSheetSceneStrategy` is not part of the core Nav3 library and so should be copied/modified to your individual requirements. + +#### 4.1.2 Obtain navigation arguments + +In Nav2, when navigation arguments are passed using the route instance, you must first obtain the route instance from `NavBackStackEntry` by calling `toRoute`. In Nav3, the route instance is directly accessible with `entry`'s lambda parameter so there's no need to obtain the route instance. + +If using ViewModels to pass navigation arguments, please check the [Nav3 recipes for ViewModels](https://github.com/android/nav3-recipes/blob/main/README.md#passing-navigation-arguments-to-viewmodels) and apply the technique most appropriate to your codebase. + +#### 4.1.3 Code example + +Existing code: + +``` +NavHost(...){ + navigation(startDestination = RouteB) { + composable{ entry -> + val id = entry.toRoute().id + Text("Route B, id: $id") + } + dialog { Text ("Dialog D") } + } +} +``` + +New code: + +``` +NavHost(...){ + navigation(startDestination = RouteB) { + composable{} + dialog {} + } +} + +NavDisplay(..., + sceneStrategy = remember { DialogSceneStrategy() }, + entryProvider = entryProvider { + entry{ route -> Text("Route B, id: ${route.id}") } + entry(metadata = DialogSceneStrategy.dialog()) { Text ("Dialog D") } + } +) +``` + +#### 4.2.1 Move NavGraphBuilder extension functions + +[`NavGraphBuilder`](https://developer.android.com/guide/navigation/design/encapsulate) [extension functions](https://developer.android.com/guide/navigation/design/encapsulate) can be refactored to `EntryProviderBuilder` extension functions. These functions can then be called inside `entryProvider`. + +Refactoring `NavHost`: + +- Copy the function call into `entryProvider` +- Inline the original function reference and keep the existing function + +![Screenshot of Android Studio showing how to inline functions](images/migration/inline.png) + +- Remove any composable content associated with the entries inside `NavHost`. Keeping blank entries for each route ensures that `NavController`'s back stack is still the source of truth for the navigation state. + +Refactoring the extension function: + +- Change the method signature from `NavGraphBuilder.existingScreen()` to `EntryProviderBuilder.existingScreen()`. +- Remove `navigation` destinations, leaving only the destinations they contain. +- Replace `composable` and `dialog` destinations with `entry` + - For `dialog` destinations add the dialog metadata following the instructions in the previous step + +#### 4.2.2 Code example + +Existing code: + +``` +NavHost(...) { + featureBSection() +} + +private fun NavGraphBuilder.featureBSection(onDetailClick: () -> Unit) { + navigation(startDestination = RouteB) { + composable { ContentRed("Route B title") } + } +} +``` + +New code: + +``` +NavHost(...) { + navigation(startDestination = RouteB) { + composable { } + } +} + +NavDisplay(..., entryProvider = entryProvider { + featureBSection() +}) + +private fun EntryProviderBuilder.featureBSection() { + entry { ContentRed("Route B title") } +} +``` + +### 4.3 In Navigator, convert NavBackStackEntry back to its route instance + +Steps: + +- Locate the line starting `val route` in `Navigator`. +- Add an `if` branch for each migrated route that converts the `NavBackStackEntry` into a route instance using `NavBackStackEntry.toRoute`. For example: + +``` +val route = + if (destination.hasRoute()) { + entry.toRoute() + } else if (destination.hasRoute()) { + entry.toRoute() + } else { + // Non migrated route + entry + } +``` + +You should now be able to navigate to, and back from, the migrated destinations. These destinations will be displayed directly inside `NavDisplay` rather than by `NavHost`. + +**Note**: When navigating (both forward and back) between destinations handled by `NavHost` and `NavDisplay`, you may see the blank destination until the transition animation has completed. + +## Step 5. [Single feature] Replace NavController with Navigator + +Goal: Within the migrated feature module, navigation events are handled by `Navigator` instead of `NavController` + +Steps: + +- Replace `NavController.popBackStack` with `Navigator.goBack` +- Replace `NavController.navigate` with Navigator.navigate + +TODO: Add warning about `navOptions` - you'll need to modify `Navigator` behavior if you don't like what it does + +For `NavController` extension functions defined by **other modules**: + +- Inline the function, leaving the other module's function in place +- Make the replacements above + +Remove Nav2 imports and module dependencies: + +- Remove all imports starting with `import androidx.navigation` +- Remove feature module dependencies on `androidx.navigation` + +At this point, this feature module has been fully migrated to Nav3. + +## Step 6. Migrate all feature modules + +Goal: Feature modules use Nav3. They don't contain any Nav2 code. + +Complete steps 3-5 for each feature module. Start with the module with the least dependencies and end with the module that contains the start route. + +Ensure that shared entries are not duplicated. + +## Step 7. Use Navigator.backStack as source of truth for navigation state + +### 7.1 Ensure Navigator is used instead of NavController everywhere + +Replace any remaining instances of: + +- `NavController.navigate` with `Navigator.navigate` +- `NavController.popBackStack` with `Navigator.goBack` + +### 7.2 Update Navigator to modify its back stack directly + +- Open `Navigator` +- In `navigate` and `goBack`: + - Remove the code that calls `NavController` + - Uncomment the code that modifies the back stack directly +- Remove all code which references `NavController` + +The final `Navigator` [should look like this](https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/migration/step7/Navigator.kt#L15). + +### 7.3 Set the app's start route + +When creating the `Navigator` specify the starting route for your app. + +``` +val navigator = remember { Navigator(navController, startRoute = RouteA) } +``` + +### 7.4 Update common navigation UI components + +If using a common navigation component, such as a `NavBar`, change the logic for when a top level route is selected to use `Navigator.topLevelRoute`. [See example here](https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/migration/step7/Step7MigrationActivity.kt#L94). + +In Nav2, it was necessary to have a type for both the navigation graph and the start destination of that graph (e.g. `BaseRouteA` and `RouteA`). This is no longer necessary so remove any redundant types for the navigation graph from the :`api` modules. Ensure that the correct types are used to identify top level routes. + +### 7.5 Remove the entryProvider fallback + +Remove the `fallback` parameter from `entryProvider` as there are no longer any unmigrated routes that must be handled by `NavHost`. + +### 7.6. Remove unused dependencies + +- Remove all remaining Nav2 dependencies from the project +- In :`core:navigation` remove any dependencies on :`feature:api` modules + +Congratulations! Your project is now migrated to Navigation 3. + +## Next steps + +In the supplied `Navigator`, the type of items in the back stack is `Any`. You may now want to change this to use stronger types, for example the `NavKey` interface provided by Nav3. diff --git a/docs/images/migration/compare_files.png b/docs/images/migration/compare_files.png new file mode 100644 index 0000000..11a9fd5 Binary files /dev/null and b/docs/images/migration/compare_files.png differ diff --git a/docs/images/migration/inline.png b/docs/images/migration/inline.png new file mode 100644 index 0000000..67d267b Binary files /dev/null and b/docs/images/migration/inline.png differ diff --git a/docs/images/migration/start_migration.png b/docs/images/migration/start_migration.png new file mode 100644 index 0000000..4e90e71 Binary files /dev/null and b/docs/images/migration/start_migration.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e4edca..25a545b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,24 +13,26 @@ # limitations under the License. [versions] -agp = "8.10.1" -kotlin = "2.2.0" -kotlinSerialization = "2.2.0" -coreKtx = "1.16.0" +agp = "8.11.2" +kotlin = "2.2.10" # Downgraded from 2.2.20 to align with KSP +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" -lifecycleViewmodel = "1.0.0-SNAPSHOT" -activityCompose = "1.12.0-alpha05" -composeBom = "2025.07.01" -navigation3 = "1.0.0-alpha07" -material3 = "1.4.0-beta01" -nav3Material = "1.0.0-SNAPSHOT" -ksp = "2.2.0-2.0.2" -hilt = "2.57" -hiltNavigationCompose = "1.2.0" +lifecycleRuntimeKtx = "2.9.4" +lifecycleViewmodel = "1.0.0-alpha04" +activityCompose = "1.12.0-alpha09" +composeBom = "2025.09.01" +navigation2 = "2.9.1" +navigation3 = "1.0.0-alpha10" +material3 = "1.4.0" +nav3Material = "1.0.0-alpha03" +ksp = "2.2.10-2.0.2" +hilt = "2.57.1" +hiltNavigationCompose = "1.3.0" +koin = "4.1.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +53,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" } @@ -59,6 +62,7 @@ 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" } +koin-compose-viewmodel = {group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4eaed7d..9adc0a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,9 +25,11 @@ pluginManagement { } mavenCentral() gradlePluginPortal() - maven { - url = uri("https://androidx.dev/snapshots/builds/13617490/artifacts/repository") - } + // Uncomment and change the build ID if you need to use snapshot artifacts. + // See androidx.dev for full instructions. + /*maven { + url = uri("https://androidx.dev/snapshots/builds//artifacts/repository") + }*/ } } dependencyResolutionManagement { @@ -35,9 +37,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { - url = uri("https://androidx.dev/snapshots/builds/13617490/artifacts/repository") - } + // Uncomment and change the build ID if you need to use snapshot artifacts. + // See androidx.dev for full instructions. + /*maven { + url = uri("https://androidx.dev/snapshots/builds//artifacts/repository") + }*/ } }