Navigation Core
viewcompose-navigation-core is ViewCompose's platform-neutral navigation state machine. It owns
immutable routes and graph declarations, strict deep-link resolution, single- and multi-stack
snapshots, rollback-safe two-phase transactions, page-lifecycle planning, and validated content,
adaptive-pane, and overlay-scene selection.
The module contains no Android or AndroidX types. Activity, predictive Back, LifecycleOwner,
SavedStateRegistryOwner, View mounting, transitions, and process-death adapters live in
viewcompose-navigation-android.
Artifact and stability
dependencies {
implementation("com.viewcompose:viewcompose-navigation-core:0.1.0-alpha03")
}
- Stability: Alpha. Snapshot compatibility and route contracts may evolve between alpha releases.
- Platform: Kotlin/JVM library targeting Java 11.
- Direct ViewCompose dependencies: none.
- Platform boundary: no Android, View, lifecycle, saved-state, or rendering types are allowed.
Graphs and routes
val graph = navGraph(
route = "root",
startDestination = NavRoute("home"),
) {
destination("home")
navigation(
route = "account",
startDestination = NavRoute("profile"),
) {
destination("profile")
destination("settings")
}
}
Route names are unique across the complete root graph. A nested graph's start destination must be a
direct child. Requesting a graph route recursively enters its start chain and produces a leaf
NavGraphResolution plus a root-to-leaf graph-owner path.
NavRoute arguments use the closed NavValue model. Constructors copy every collection. Route
equality includes arguments, so SingleTop treats the same route name with different arguments as a
different request.
NavEntryId identifies a concrete destination or graph owner, not a route. IDs remain stable while
their owners are retained and must be persisted with navigation snapshots. Graph owners allow an
Android host to share lifecycle, saved state, and ViewModels across destinations inside one graph
instance without putting Android concepts in this module.
Typed route contract
data class ProfileRoute(val userId: Long)
val ProfileDestination = NavRouteSpec(
name = "profile",
encodeArguments = { profile: ProfileRoute ->
mapOf("userId" to NavValue.LongValue(profile.userId))
},
decodeArguments = { arguments ->
ProfileRoute((arguments.getValue("userId") as NavValue.LongValue).value)
},
)
fun typedRouteSample() {
val graph = navGraph(
route = "root",
startDestination = NavRoute("home"),
) {
destination("home")
destination(ProfileDestination)
}
val route = ProfileDestination.encode(ProfileRoute(userId = 42L))
val entry = NavEntry(NavEntryId("profile-42"), graph.resolve(route).destination)
check(entry.toRoute(ProfileDestination).userId == 42L)
}
NavRouteSpec<T> is the single application declaration for graph identity, typed encoding, and
entry decoding. It always produces the existing immutable NavRoute; the graph retains only the
stable name, while snapshots and restore continue to persist only closed NavValue arguments.
Keep the name and schema compatible across process recreation. Decoder failures are explicit, and
route-name mismatch is rejected before the application decoder runs.
Two-phase transactions
when (val preparation = controller.prepare(NavCommand.Push(NavRoute("details")))) {
is NavPreparation.NoChange -> Unit
is NavPreparation.Ready -> preparation.transaction.use { transaction ->
// First mount transaction.after and apply owner lifecycle changes.
transaction.commit()
}
}
prepare computes a prospective immutable state and entry delta without publishing either. A host
renders and applies lifecycle ownership first, then calls commit. If mounting fails, rollback
releases the pending slot and leaves the committed state unchanged. Closing a prepared transaction
rolls it back automatically.
Only one transaction may be pending per controller. Transaction completion is single-use and
synchronized. A NavStackMutation spans every retained stack so selecting tabs or opening a deep
link cannot leave platform owners out of sync.
No-change outcomes are explicit:
- a root entry cannot be popped;
SingleTop, replace, or reset already targets the effective destination;- a selected stack is already active with the requested policy.
Atomic return results
val selectedItem = NavResultKey.text("catalog.selection")
val preparedResultPop = controller.prepare(
NavCommand.PopWithResult(selectedItem.encode("item-42")),
)
check(preparedResultPop is NavPreparation.Ready)
PopWithResult carries one typed NavValue while removing the active top. Only a committed
transaction delivers to after.top; Core owns neither callbacks nor Lifecycle.
Independent retained stacks
val configuration = NavStackConfiguration(
initialStackId = NavStackId("home"),
stacks = listOf(
NavStackSpec(NavStackId("home"), NavRoute("home")),
NavStackSpec(NavStackId("account"), NavRoute("profile")),
),
rootBackBehavior = NavRootBackBehavior.PreviousStack,
)
val controller = NavBackStackController.create(configuration, graph)
Each declared stack owns an independent non-empty back stack and independent destination and graph
IDs. Preserve resumes a stack exactly where it was left; PopToRoot removes entries above its
root before selection. Selection history records inactive stacks from oldest to newest.
systemBackCommand() is a pure query. It returns Pop for a non-root active stack, optionally
returns PopStackHistory at root, or returns null so the Android host can delegate Back outward.
NavStackSetSnapshot validates that no destination or graph-owner identity crosses stack
boundaries. This invariant prevents lifecycle, saved-state, and ViewModel ownership from leaking
between tabs.
Deep links
val graph = navGraph(
route = "root",
startDestination = NavRoute("home"),
) {
destination("home")
destination(
route = "shared-image",
deepLinks = listOf(
NavDeepLink(
action = "android.intent.action.SEND",
mimeType = "image/*",
),
),
)
}
val result = graph.resolveDeepLink(
NavDeepLinkRequest(
action = "android.intent.action.SEND",
mimeType = "image/png",
),
)
check((result as NavDeepLinkResolution.Matched).match.route.name == "shared-image")
Declarations are strict URI, action, MIME, or combined allowlists. Every declared dimension is
required; extra request dimensions are inert. MIME values compare case-insensitively and support
exact type/subtype, type/*, */subtype, and */* constraints. Actions compare exactly.
Combined declarations rank before a matching single-dimension declaration. URI specificity then
ranks static path segments above placeholders and declared query values. Equally specific winners
are rejected instead of depending on declaration order.
URI placeholders occupy a complete path segment or query value. URI fragments, user info,
malformed percent encoding, invalid UTF-8, duplicate query names, undeclared types, and partial
placeholders are rejected. Typed floating-point values must be finite and booleans accept only
lowercase true or false.
Extra input query parameters are tolerated but inert. An undeclared value never enters
NavRoute.arguments, changes specificity, resolves ambiguity, selects a retained stack, or chooses
a launch mode. Applications that require an exact or signed URL validate the complete input before
passing it to the resolver.
Resolution returns one of four outcomes:
Matchedcontains the winning declaration and decodedNavRoute;NoMatchmeans a valid request did not satisfy any complete declaration;Rejectedreports malformed input, typed-argument failure, or an ambiguous best match;Unsupportedmeans the controller was created without a graph.
A host converts a match to OpenDeepLink, which mutates the target stack and selects it in one
transaction. String URI resolution is a convenience overload over NavDeepLinkRequest; it is not
a second matching implementation.
This Alpha slice intentionally replaces NavDeepLinkRejection.matchingPatterns with
NavDeepLinkRejection.candidates. Consumers must inspect the immutable declarations when rendering
diagnostics because an action-only or MIME-only candidate has no URI pattern. No deprecated bridge
or parallel string projection is retained.
Lifecycle planning
NavScene replaces parallel visible and interactive ID sets with one validated, bottom-to-top
semantic projection. Each NavSceneEntry records presence, visibility, interaction, coarse
transition phase, content-pane role, and content/overlay layer. It derives independent scene and
entry lifecycle caps without Android types or frame-rate progress.
NavLifecyclePlanner accepts destination records plus that scene and applies one rule:
effective destination lifecycle = min(host cap, scene cap, entry cap)
Prepared and hidden entries cap at Created; covered and active-transition entries cap at
Started; only retained, visible, interactive, settled entries may reach Resumed. A popped entry
that is still exiting caps at Created, and terminal removal targets Destroyed. An active
transition scene rejects every interactive entry, preventing premature Resumed state by
construction.
Owners removed from retention transition to Destroyed and cannot be resurrected. Downward and
destroy transitions are ordered before upward transitions so replacing the interactive destination
does not temporarily leave two owners resumed. Graph-owner targets are the highest effective state
among their descendants, so parents never fall below active children. The Android module applies
the resulting immutable NavLifecyclePlan to concrete owners.
val list = NavEntry(NavEntryId("list"), NavRoute("list"))
val detail = NavEntry(NavEntryId("detail"), NavRoute("detail"))
val scene = NavScene(
listOf(
NavSceneEntry(
entryId = list.id,
presence = NavEntryPresence.Retained,
visibility = NavSceneVisibility.Hidden,
interaction = NavSceneInteraction.NonInteractive,
transitionPhase = NavSceneTransitionPhase.Settled,
paneRole = null,
),
NavSceneEntry(
entryId = detail.id,
presence = NavEntryPresence.Retained,
visibility = NavSceneVisibility.Visible,
interaction = NavSceneInteraction.Interactive,
transitionPhase = NavSceneTransitionPhase.Settled,
paneRole = NavPaneRole.Primary,
),
),
)
val plan = NavLifecyclePlanner.plan(
currentStates = mapOf(
list.id to NavEntryLifecycleState.Resumed,
detail.id to NavEntryLifecycleState.Created,
),
entries = listOf(list, detail),
scene = scene,
hostState = NavHostLifecycleState.Resumed,
)
check(plan.targetStates[list.id] == NavEntryLifecycleState.Created)
check(plan.targetStates[detail.id] == NavEntryLifecycleState.Resumed)
check(plan.transitions.first().entryId == list.id)
Unified execution reducer
NavExecutionReducer is the single policy boundary above stack transactions, scene layouts, and
lifecycle projection. Its settled, transition, and predictivePreview entry points make each
event's preconditions explicit, then delegate to one implementation and return the same immutable
NavExecutionPlan. reconcile retains the plan's stack and scene decision while recalculating
outer-host lifecycle, presentation inventory, retention, or Back ownership.
The plan contains the candidate or committed stack delta, exact semantic scene, ordered lifecycle
targets, presentation prepare/refresh/retain/evict/dispose lists, input/focus/accessibility and Back
ownership, rendering suspension, pre-commit rollback, and terminal cleanup. It contains IDs and
Core values only—never a View, LifecycleOwner, callback, or animation progress value. Platform
adapters must prepare every requested presentation before committing a transaction, publish effects
in plan order after commit, and use the recorded rollback or cleanup lists instead of deriving a
second policy from controller state.
val plan = NavExecutionReducer.transition(
currentLifecycleStates = mapOf(
before.top.id to NavEntryLifecycleState.Resumed,
),
transaction = transaction,
beforeSceneLayout = NavSceneLayout(
NavPaneScene(listOf(NavPane(NavPaneRole.Primary, before.top.id))),
),
afterSceneLayout = NavSceneLayout(
NavPaneScene(listOf(NavPane(NavPaneRole.Primary, transaction.after.top.id))),
),
hostState = NavHostLifecycleState.Resumed,
presentedEntryIds = listOf(before.top.id),
maxRetainedHiddenPresentations = 0,
)
// A platform adapter prepares these identities before committing the stack.
check(plan.preparePresentationEntryIds == listOf(transaction.after.top.id))
check(plan.inputEntryIds.isEmpty())
check(plan.rollbackOwnerEntryIds == listOf(transaction.after.top.id))
check(plan.lifecycle.targetStates.values.none(NavEntryLifecycleState.Resumed::equals))
All reducer calls are side-effect free and linear in retained entries, graph depth, current owners,
and presentations. null is the explicit unbounded hidden-presentation limit; non-negative values
are deterministic oldest-first bounds. The API is Alpha and intentionally has no legacy dual-plan
bridge.
Scene strategies and adaptive panes
NavSceneLayout combines non-empty content panes with a bottom-to-top overlay suffix, preserving one
stack order for z-order, Back, results, and restoration.
val overlayStrategy = NavSceneStrategies.trailingOverlays { entry ->
entry.route.name.endsWith("-dialog")
}
val layout = resolveNavSceneLayout(
snapshot = snapshot,
maxPaneCount = 1,
sceneStrategies = listOf(overlayStrategy),
)
check(layout.contentPaneScene.visibleEntryIds == setOf(home.id))
check(layout.overlayEntryIds == listOf(dialog.id))
check(layout.interactiveEntryIds == setOf(dialog.id))
resolveNavSceneLayout selects the first non-null strategy. Its bounded projectContent applies
validated pane policy to a stack prefix; trailingOverlays classifies consecutive top entries.
Strategies must be deterministic and side-effect free.
NavPaneStrategy converts the active stack into one to three logical panes. Single exposes only
the top destination. BackStack places the newest retained destinations into contiguous primary,
secondary, and tertiary panes.
Always execute custom strategies through calculateValidated. Validation enforces the pane limit,
rejects entries outside the active stack, and requires the active top to remain visible. A
NavPaneScene treats all visible panes as interactive by default; hosts may derive a narrower focus
policy when constructing NavScene.
Save and restore contract
Persist the complete immutable NavStackSetSnapshot, including route arguments, destination IDs,
graph-owner entries, active stack, and selection history. The Android integration encodes this model
into SavedStateRegistry values.
Graph-aware state must be restored with the current NavGraph. Restore fails closed if a route was
removed, resolves to another leaf, or moved to a different graph hierarchy, because reusing its old
platform owner would be unsafe. Multi-stack restore also requires the current configuration to have
exactly the saved stack IDs. Pending transactions are never part of persisted state.
Related documentation
- Complete navigation guide
- Lifecycle and saved-state architecture
- Session container architecture
- Source documentation and API comment standard
The complete generated reference is available in the
viewcompose-navigation-core API tree.
Compatibility notes
The scene-projection API is an Alpha hard cut. NavExecutionReducer now accepts
beforeSceneLayout/afterSceneLayout instead of pane-only fields. Replace both
NavLifecyclePlanner.plan overloads
that accepted retainedEntryIds, visibleEntryIds, and interactiveEntryId(s) with the single
entries plus scene overload. No deprecated bridge or dual planner exists.
The 0.1.0-alpha03 line establishes immutable snapshots, single-pending two-phase transactions,
independent retained stacks, strict URI matching, graph-hierarchy validation, lifecycle planning,
scene strategies, overlay suffixes, and three logical pane roles. Persist only committed snapshots;
recompute layouts from current strategies after restore.