Skip to content

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 — preserves rememberSaveable values across navigation
  • rememberViewModelStoreNavEntryDecorator — retains ViewModel instances 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(...) — inspect backStack or backStack.last() directly
  • isBackFromAlertDialog(args) — this detection is no longer needed.showAlertDialog callbacks are called directly
  • buildNavGraph(...) — 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()
    }
}

Last update: April 30, 2026