Navigation 3 Migration Guide¶
This document covers the upgrade from Jetpack Navigation 2 to Navigation 3 in the SAP BTP SDK Android Flow Compose library. It describes why the upgrade is worthwhile, exactly what changed in the public API, and how to migrate.
Why Navigation 3¶
Compose-First Back Stack¶
Navigation 2 stores back-stack state inside an opaque NavController. In Navigation 3, the back stack is a plain SnapshotStateList<String>, which is ordinary Compose state. Compose recomposes automatically when navigation changes, and the SDK exposes a read-only snapshot of it as BaseFlow.backStack, so custom flows can inspect navigation state directly.
No NavController Complexity in Custom Flows¶
In Navigation 2, custom flows that needed non-trivial navigation had to obtain a NavController reference, call NavOptions.Builder(), and reason about destination IDs versus route strings. Navigation 3 replaces all of that with two simple methods: navTo(route) to push and navBackTo(dest, popDest) to pop.
Improved State Preservation¶
Navigation 3 separates state preservation into opt-in composable decorators:
rememberSaveableStateHolderNavEntryDecorator— preservesrememberSaveablevalues across navigationrememberViewModelStoreNavEntryDecorator— retainsViewModelinstances per navigation entry
Both are visible in the composable tree and easy to reason about, unlike Navigation 2's implicit NavBackStackEntry lifecycle.
Native Predictive Back Support¶
Navigation 3's NavDisplay supports Android's predictive back gesture by default. The SDK connects this through the new backPressHandlerEnabled() / onBackPress() pair on BaseFlow, giving custom flows full control over the gesture without registering with OnBackPressedDispatcher manually.
Simpler Composable Content Signature¶
In Navigation 2, each screen's content lambda received a NavBackStackEntry, which exposed Navigation 2-specific state (arguments, lifecycle, saved-state handle) even for flows that didn't use any of it. In Navigation 3 the lambda receives only the route String. If a step needs extra data it reads it from getCustomBundle() or its own ViewModel, which is the correct pattern anyway.
No Forced @Serializable Routes¶
Navigation 2 2.8+ type-safe navigation requires @Serializable destination objects. Navigation 3 accepts any type as a back-stack key. The SDK uses plain String routes, which fits the runtime-built, hierarchical flow model naturally.
Smaller Transitive Footprint¶
Navigation 3 has no dependency on the Navigation Fragment artifact or the XML inflation system. Apps that use only Compose benefit from a smaller APK and no unused Fragment machinery at runtime.
API Changes¶
Gradle Dependencies¶
| Navigation 2 | Navigation 3 |
|---|---|
androidx.navigation:navigation-runtime-ktx:2.x |
androidx.navigation3:navigation3-runtime:1.0.1 |
androidx.navigation:navigation-compose:2.x |
androidx.navigation3:navigation3-ui:1.0.1 |
| (none) | androidx.lifecycle:lifecycle-viewmodel-navigation3:1.0.1 |
Update libs.versions.toml (check the Navigation 3 release page for the latest stable version):
[versions]
androidx_navigation3 = "1.0.1"
[libraries]
androidx-nav3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx_navigation3" }
androidx-nav3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx_navigation3" }
androidx-nav3-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx_navigation3" }
Remove the Navigation 2 navigation-compose and navigation-runtime-ktx entries.
BaseFlow — Navigation Methods¶
This is the only breaking surface for consumers who subclass BaseFlow.
Renamed / Replaced Methods¶
| Navigation 2 | Navigation 3 | Notes |
|---|---|---|
navigateTo(route, popRoute?, args?) |
navTo(route, popCurrent) |
Push route; optionally replace the current top entry |
navigateBack() |
navBackTo() |
Pop top entry; terminates flow if stack is empty |
| (none) | navBackTo(dest: String, popDest: Boolean = false) |
Pop back to dest; optionally remove dest too |
Removed Methods (No Replacement Needed)¶
| Navigation 2 | Reason removed |
|---|---|
addOnDestinationChangedListener(listener) |
No NavController in Navigation 3 — use backStack to inspect state |
isBackFromAlertDialog(args) |
Dialogs no longer navigate via the back stack; this helper is obsolete |
addSingleDialogStep(step) |
SingleDialogStep class is removed (see the SingleDialogStep removed section below) |
Removed Internal Method¶
| Navigation 2 | Reason removed |
|---|---|
buildNavGraph(builder) |
Navigation 2 NavGraphBuilder is gone; replaced internally by flatten() |
onBackPress Signature Change¶
// Navigation 2
open fun onBackPress(route: String)
// Navigation 3
open fun onBackPress()
In Navigation 2 the current route was passed as a parameter. In Navigation 3 read backStack.last() if you need the current route.
New: backPressHandlerEnabled()¶
// Navigation 3 only — opt-in to intercept system back gestures
open fun backPressHandlerEnabled(): Boolean = false
In Navigation 2 the onBackPress(route) override was always active. In Navigation 3 the system back gesture is only intercepted when the top-level flow overrides backPressHandlerEnabled() to return true.
New: backStack Property¶
// Navigation 3 only
val backStack: List<String>
A read-only snapshot of the current navigation stack. Not available in Navigation 2.
SingleStep — Content Lambda Signature¶
// Navigation 2
val content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
// Navigation 3
val content: @Composable (String) -> Unit // String = route name
Any step content that used the NavBackStackEntry (e.g. to read arguments or savedStateHandle) must be updated. Pass data through getCustomBundle() or a scoped ViewModel instead.
SingleDialogStep Removed¶
SingleDialogStep and BaseFlow.addSingleDialogStep() are removed in Navigation 3. Dialogs are now shown through BaseFlow.showAlertDialog(), which manages dialog visibility via Compose state rather than navigation.
start() / flowDone() Parameter Rename¶
// Navigation 2
open fun start(popRoute: String? = null)
open fun flowDone(routeOrFlowName: String, popRoute: String? = null)
// Navigation 3
open fun start(popCurrent: Boolean = false)
open fun flowDone(routeOrFlowName: String, popCurrent: Boolean = false)
The popRoute: String? parameter (which popped a specific route by name) is replaced by popCurrent: Boolean (which pops whatever entry is currently at the top). If you were overriding start() or calling flowDone() with a non-null popRoute, adjust those call sites.
Unchanged Public API¶
Everything below is identical between Navigation 2 and Navigation 3:
| Class / method | Status |
|---|---|
FlowUtil.startFlow() |
Unchanged |
FlowContext |
Unchanged |
FlowContextRegistry |
Unchanged |
FlowType |
Unchanged |
FlowOptions |
Unchanged |
FlowStateListener |
Unchanged |
FlowActionHandler |
Unchanged |
FlowScreenExtension |
Unchanged |
CustomStepInsertionPoint |
Unchanged |
BaseFlow.addSingleStep() |
Unchanged |
BaseFlow.addNestedFlow() |
Unchanged |
BaseFlow.terminateFlow() |
Unchanged |
BaseFlow.terminateFlowWithMessage() |
Unchanged |
BaseFlow.showAlertDialog() |
Unchanged |
BaseFlow.getCustomBundle() |
Unchanged |
BaseFlow.populateCustomBundleData() |
Unchanged |
BaseFlow.populateFinishData() |
Unchanged |
BaseFlow.updateAppConfigBeforeActivation() |
Unchanged |
BaseFlow.getChildFlowByName() |
Unchanged |
Migration Guide¶
Step 1 — Update Gradle Dependencies¶
Replace the Navigation 2 navigation artifacts with Navigation 3 (see the Gradle dependencies section above). Remove any direct dependency on navigation-compose or navigation-runtime-ktx in your own modules.
Step 2 — Update addSingleStep Content Lambdas¶
The content lambda signature changed. The NavBackStackEntry receiver is gone; the lambda now receives only the route String.
// Navigation 2
addSingleStep("step_form") { entry ->
val userId = entry.arguments?.getString("userId")
MyFormScreen(userId)
}
// Navigation 3
addSingleStep("step_form") { _ ->
val userId = getCustomBundle()?.getString("userId")
MyFormScreen(userId)
}
If your step needed entry.savedStateHandle, move that state into a ViewModel scoped to the step.
Step 3 — Replace Navigation Method Calls¶
Search your custom BaseFlow subclasses for the Navigation 2 method names and replace them:
navigateTo(route) → navTo(route)
// Navigation 2
navigateTo("step_confirm")
// Navigation 3
navTo("step_confirm")
navigateTo(route, popRoute) → navTo(route, popCurrent = true) or navBackTo + navTo
If popRoute was the entry immediately below the current one (that is, you were replacing the top):
// Navigation 2
navigateTo("step_b", popRoute = "step_a")
// Navigation 3 — step_a is the current top, replace it
navTo("step_b", popCurrent = true)
If popRoute referred to a specific anchor deeper in the stack:
// Navigation 2
navigateTo("step_sign_in", popRoute = "step_user_list")
// Navigation 3 — pop down to user_list (kept, popDest defaults to false), then push sign_in
navBackTo("step_user_list")
navTo("step_sign_in")
navigateBack() → navBackTo()
// Navigation 2
navigateBack()
// Navigation 3
navBackTo()
Step 4 — Update onBackPress Override¶
The signature drops the route parameter. Read backStack.last() if you need the current route.
// Navigation 2
override fun onBackPress(route: String) {
if (route == "step_form") navTo("step_summary")
else navigateBack()
}
// Navigation 3
override fun backPressHandlerEnabled(): Boolean = true
override fun onBackPress() {
if (backStack.lastOrNull() == "step_form") navTo("step_summary")
else navBackTo()
}
Note the addition of backPressHandlerEnabled(). Without overriding it to return true, onBackPress() is never called.
Step 5 — Replace addSingleDialogStep Usages¶
SingleDialogStep is removed. Convert any dialog previously navigated to as a destination to a showAlertDialog() call from within the relevant step.
// Navigation 2
addSingleDialogStep(SingleDialogStep(context, "step_confirm_dialog") { _ ->
ConfirmDialog(
onConfirm = { flowDone("step_confirm_dialog") },
onDismiss = { navigateBack() }
)
})
// Navigation 3 — show the dialog directly from the step that needs it
showAlertDialog(
message = "Are you sure?",
positiveButtonText = android.R.string.ok,
onConfirm = { flowDone("step_current") },
negativeButtonText = android.R.string.cancel
)
Step 6 — Remove Navigation 2-Only Method Calls¶
Delete any calls to the removed methods:
addOnDestinationChangedListener(...)— inspectbackStackorbackStack.last()directlyisBackFromAlertDialog(args)— this detection is no longer needed.showAlertDialogcallbacks are called directlybuildNavGraph(...)— internal method, remove if called from custom code
Also delete any Navigation 2 imports:
// Remove these
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.NavBackStackEntry
import androidx.compose.animation.AnimatedContentScope
Step 7 — Update start() / flowDone() Call Sites if Needed¶
If you were calling flowDone("step_x", popRoute = "step_x") (passing a non-null string), change to:
// Navigation 2
flowDone("step_x", popRoute = "step_x")
// Navigation 3
flowDone("step_x", popCurrent = true)
Step 8 — Build and Test¶
./gradlew testDebugUnitTest
./gradlew flows-compose:connectedDebugAndroidTest
FlowUtil.startFlow(), FlowContext, FlowOptions, FlowStateListener, and FlowActionHandler are all unchanged. Existing flow configuration and lifecycle callback code require no changes.
Quick Reference¶
Custom Flow — Before (Navigation 2)¶
class MyFlow(context: Context) : BaseFlow(context, "my_flow"),
NavController.OnDestinationChangedListener {
override fun initialize() {
addSingleStep("step_a") { entry ->
val value = entry.arguments?.getString("key")
ScreenA(value) {
navigateTo("step_b")
}
}
addSingleStep("step_b") { _ ->
ScreenB {
navigateBack()
}
}
}
override fun onBackPress(route: String) {
if (route == "step_b") navigateTo("step_a", popRoute = "step_b")
else navigateBack()
}
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
if (!isBackFromAlertDialog(arguments)) {
// handle destination change
}
}
}
Custom Flow — After (Navigation 3)¶
class MyFlow(context: Context) : BaseFlow(context, "my_flow") {
override fun initialize() {
addSingleStep("step_a") { _ ->
val value = getCustomBundle()?.getString("key")
ScreenA(value) {
navTo("step_b")
}
}
addSingleStep("step_b") { _ ->
ScreenB {
navBackTo()
}
}
}
override fun backPressHandlerEnabled(): Boolean = true
override fun onBackPress() {
if (backStack.lastOrNull() == "step_b") navBackTo("step_a")
else navBackTo()
}
}