Nav 9 min read

Navigation3 1.2.0: Deep Links and Result Passing Fill the Two Biggest Gaps from Stable

Navigation3 1.1.4 shipped stable in June with a deliberate disclaimer: deep link support wasn't there yet. The library was production-ready for in-app navigation, but if your app handles incoming URIs — from a notification tap, a share target, a widget shortcut, an App Link — you were stuck on the old Navigation Component or building your own dispatch layer on top of Navigation3. That gap closes in the 1.2.0 alpha series.

Alpha05 (July 1) landed the full deep link API surface: DeepLinkRequest, DeepLinkUri, UriDeepLinkMatcher, and a typed extras mechanism. Alpha06 (July 15) followed with targeted fixes for edge cases in URI scheme matching and wildcard fragment handling. Neither is stable — this is still alpha, and navigation is foundational enough that you should test exhaustively before shipping — but the API shape is clear and the migration path from alpha05 to alpha06 was a handful of constructor calls replacing factory functions.

The same alpha series also adds something that wasn't in the original 1.1.4 scope at all: a ResultEventBus for passing typed data back from a destination. The shared ViewModel workaround most Navigation3 early adopters have been using works, but it's awkward. The bus is cleaner and makes the dependency explicit rather than hiding it behind a shared data holder.

Jul 15
1.2.0-alpha06 — latest drop in the series
New
UriDeepLinkMatcher resolves URIs to typed NavKeys
New
ResultEventBus — typed result passing between destinations

1. The Deep Link Model: DeepLinkRequest and DeepLinkUri

Every incoming deep link — whether from an intent, a URI string, or a share action — starts as a DeepLinkRequest. It wraps a DeepLinkUri (Navigation3's KMP-compatible replacement for android.net.Uri) plus an optional typed extras map.

// From an Intent on Android
val request = DeepLinkRequest(
    intent = intent,
    extras = emptyMap()
)

// From a URI string (KMP-compatible, compiles on all targets)
val request = DeepLinkRequest("https://app.example.com/profile/42")

// With typed extras
val request = DeepLinkRequest(
    deepLinkUri = DeepLinkUri.parse("example://settings"),
    extras = requestExtras {
        put(SomeExtrasKey, someValue)
    }
)

The factory-function style (DeepLinkRequest.fromUri(), .fromIntent(), .fromAction(), etc.) that existed in pre-alpha05 builds is gone. Everything is constructors now. If you were on an earlier alpha, that's the one mechanical migration you need to do before upgrading to alpha06.

DeepLinkUri matters specifically for multiplatform projects. If you share a navigation layer between Android, JVM desktop, and web targets, you use DeepLinkUri throughout — no platform-specific imports. The Android-only Intent-accepting constructor compiles only in Android source sets; the URI string constructor compiles everywhere.

Two Android-specific extras keys exist for cases the URI alone doesn't capture: ActionExtrasKey (for intent actions like android.intent.action.SEND) and MimeTypeExtrasKey (for share-to MIME types). Both come with convenience functions — actionExtra() and mimeTypeExtra() — that build a properly typed extras entry without casting.

2. UriDeepLinkMatcher — Resolving URI Patterns to NavKeys

A DeepLinkMatcher<T> is the abstract base: it takes a DeepLinkRequest and returns a MatchResult<T> where T is a @Serializable NavKey. UriDeepLinkMatcher is the concrete implementation for the common case: map a URI pattern to a NavKey with typed path and query argument extraction.

@Serializable data class ProfileKey(val userId: Int)
@Serializable data class ArticleKey(val slug: String, val section: String?)
@Serializable object SettingsKey

val matchers = listOf(
    UriDeepLinkMatcher(
        uriPattern = "https://example.com/profile/{userId}",
        navKey = ProfileKey::class
    ),
    UriDeepLinkMatcher(
        uriPattern = "https://example.com/{section}/article/{slug}",
        navKey = ArticleKey::class
    ),
    UriDeepLinkMatcher(
        uriPattern = "example://settings",
        navKey = SettingsKey::class
    )
)

// In your Activity or NavHost
val matchResult = matchers.firstNotNullOfOrNull { it.match(deepLinkRequest) }
if (matchResult != null) {
    navBackStack.add(matchResult.key)
}

Pattern syntax supports primitive path arguments ({userId}), optional segments, wildcards, query parameters, and fragments. When the NavKey's field is typed as Int, the matcher extracts the path segment and converts it — no manual string parsing. For nullable fields in the key, a missing argument produces null rather than throwing. That exception behaviour was a bug fixed across alpha05 and alpha06 — both MissingFieldException and the wildcard fragment edge case are resolved as of alpha06.

Scheme matching gotcha: An alpha05 fix means a UriDeepLinkMatcher configured for HTTPS now correctly rejects plain HTTP requests — they no longer silently match. If you have matchers intended to handle both schemes, you need two matchers or a pattern without a scheme prefix. Verify your patterns on alpha06.

StaticKeyDeepLinkMatcher is the zero-configuration alternative for deep links that carry no payload — a fixed URI that always navigates to the same destination. Useful for home, onboarding, or settings deep links where the URI is just a trigger.

The DeepLinkMatcher<T> base class is now covariant (out T) in alpha05 — a change that matters if you're using hierarchical NavKey types. Before, you needed explicit casts when storing matchers in a List<DeepLinkMatcher<BaseKey>> alongside matchers for subtypes. With covariance, you don't.

3. ResultEventBus — Passing Data Back Without a Shared ViewModel

Every navigation library eventually has to solve the same problem: screen A navigates to screen B, the user does something in B, and A needs to know the outcome. The Navigation Component used savedStateHandle.getLiveData("result") — functional but verbose and stringly-typed by default. With Navigation3 1.1.4, the cleanest approach was a shared ViewModel that B writes to and A observes. That works, but the coupling between two destinations becomes invisible in the navigation graph.

The ResultEventBus makes the dependency explicit at the composition layer:

// Hoist the bus above both destinations — it lives outside NavDisplay
val resultBus = rememberResultEventBus<String>()

NavDisplay(
    backStack = navBackStack,
    entryProvider = entryProvider {
        entry<HomeKey> {
            val selected by resultBus.conflateAsState()
            HomeScreen(
                selectedItem = selected,
                onOpenPicker = { navBackStack.add(PickerKey) }
            )
        }
        entry<PickerKey> {
            ItemPickerScreen(
                onItemSelected = { item ->
                    resultBus.send(item)
                    navBackStack.removeLastOrNull()
                }
            )
        }
    },
    navEntryDecorators = listOf(
        RememberResultEventBusNavEntryDecorator(resultBus)
    )
)

Because rememberResultEventBus() is called outside NavDisplay, the bus isn't tied to any entry's lifecycle — it survives back stack changes and doesn't reset when PickerKey is popped. conflateAsState() gives HomeScreen a Compose state value that recomposes whenever ItemPickerScreen sends. Only the latest value is retained, which is the right default for a result — you don't want to re-process stale sends from a previous opening of the picker.

ResultEffect is the imperative alternative for cases where you want to execute a side effect (show a snackbar, log an event, trigger a haptic) rather than hold state:

ResultEffect(resultBus) { result ->
    snackbarHostState.showSnackbar("Selected: $result")
}

If you need ViewModel-level access — to debounce, combine with other state, or survive process death — rememberResultEventBus() lets you hoist the bus further up the composition tree and pass it to a ViewModel through dependency injection or SavedStateHandle. The rememberResultEventBus API (new in alpha05) is specifically there for this hoisting pattern; the earlier API forced the bus to live inside the decorator.

In Nodat, where I have a multi-step note creation flow that needs to pass the created note ID back to the list screen, this pattern maps directly — the previous SharedViewModel approach meant the VM outlived the creation flow and held stale state. The bus scopes the lifetime correctly without ceremony.

4. NavigationBackHandler — The onBackCancelled Addition

NavigationBackHandler shipped in 1.1.4 for predictive back integration. Alpha05 refined its callbacks: the confusing onBack/onBackCompleted split was collapsed into onBackCompleted (committed gesture — the back stack has already been popped) and a new onBackCancelled (user started the gesture but reversed course).

NavigationBackHandler(
    sceneState = sceneState,
    backStack = navBackStack,
    onBackCompleted = {
        // Gesture committed — NavBackStack already updated
    },
    onBackCancelled = {
        // Gesture abandoned — stack unchanged
        // Reset any in-progress exit animation here
    }
)

Most apps don't need onBackCancelled. If you're animating a shared element transition or a custom exit animation in response to gesture progress — tracking the drag distance to move a card off-screen, for example — you need to restore that animation when the gesture cancels. This is the hook that was missing before.

Dependency Update

dependencies {
    implementation("androidx.navigation3:navigation3-runtime:1.2.0-alpha06")
    implementation("androidx.navigation3:navigation3-ui:1.2.0-alpha06")
}

Both artifacts are required. navigation3-runtime contains DeepLinkRequest, DeepLinkUri, DeepLinkMatcher, and NavigationBackHandler. navigation3-ui contains ResultEventBus, RememberResultEventBusNavEntryDecorator, and the NavDisplay integration.

When to Move to 1.2.0-alpha06

Split this into two decisions by project type.

New projects: Start on 1.2.0-alpha06 rather than 1.1.4 stable. Deep linking is a day-one feature for almost any real app — handling notification taps, App Links, share targets, widget shortcuts. Building on 1.1.4 and adding deep link support later means a bigger migration when 1.2.x stabilizes. Starting with alpha06 means you build around the deep link API from the start rather than retrofitting it.

Existing production apps on 1.1.4 stable should hold until 1.2.x stabilizes unless the deep link gap is actively blocking you. Navigation is load-bearing — alpha-to-alpha breaking changes have happened before in this series (the alpha05 factory-function removal being the most recent example) and will likely happen again before 1.2.0 ships stable.

The bigger picture: With 1.2.0-alpha series, Navigation3 has the feature set to replace the Navigation Component in a real production app — type-safe destinations, developer-controlled back stack, deep links, result passing, predictive back, and KMP support for shared navigation logic across platforms. The two remaining practical questions are stability (how many more alpha iterations before a stable ships) and ecosystem tooling (deep link graph visualization in Android Studio, SafeArgs equivalent, Compose compiler integration). Those will come — but the core API surface is there now, and the direction is clearly right.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment