A Jetpack Compose navigation library that gives you what the Fragment Navigation Component editor provides for XML — a live, visual map of every screen and how they connect — but for Compose, viewable directly inside your running app.
With plain NavHost or manual when navigation, screen relationships live only in scattered
navigate() calls. There is no single place to see the shape of your app. Compose Navigator fixes
that by making the navigation graph the single source of truth: you declare it once, and it
drives both runtime navigation and the interactive map.
- Declarative graph DSL — define screens, groups, icons, and transitions in one place
- Runtime navigation — back stack,
navigate,popBackStack,popTo, system back support - Interactive navigation map — pan, pinch-to-zoom, tap a node to jump to that screen
- Live back-stack highlighting — current screen and active path are highlighted on the map
- Runtime edge discovery — undeclared transitions are recorded and drawn automatically
- Orphan detection — unreachable screens are highlighted so you can spot dead ends
- Mermaid export — generate diagrams for READMEs and PRs
- Zero code generation — no KSP, no annotations, no IDE plugin required
| Module | Description |
|---|---|
:navigator |
The library (com.majid2851:compose-navigator:0.1.0) |
:sample |
Demo app with ~10 screens showcasing the navigation map |
Copy or submodule the :navigator folder into your project, then add it to settings.gradle.kts:
include(":navigator")In your app module's build.gradle.kts:
dependencies {
implementation(project(":navigator"))
}Make sure your app module has Compose enabled:
android {
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.10.1")
}From this repository:
./gradlew :navigator:publishReleasePublicationToMavenLocalThen in your project's settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}And in your app module:
dependencies {
implementation("com.majid2851:compose-navigator:0.1.0")
}| Minimum | |
|---|---|
minSdk |
24 |
compileSdk |
35 |
| Kotlin | 2.0+ |
| Jetpack Compose | BOM 2024.09.00+ |
| Material 3 | required (used by the map UI) |
import com.majid2851.navigator.dsl.navGraph
val appGraph = navGraph(start = "home") {
screen("home", label = "Home", group = "Main", icon = Icons.Filled.Home) {
Button(onClick = { navigator.navigate("profile") }) {
Text("Go to Profile")
}
}
screen("profile", label = "Profile", group = "Account") {
OutlinedButton(onClick = { navigator.popBackStack() }) {
Text("Back")
}
}
// Optional: show this transition on the map before the user ever walks it
edge(from = "home", to = "profile", label = "Open profile")
}Inside every screen { } block you are in a ScreenScope and have access to:
navigator— theNavigatorControllerroute— the current screen's route string
import com.majid2851.navigator.map.NavigationMapScaffold
import com.majid2851.navigator.runtime.NavigatorHost
import com.majid2851.navigator.runtime.rememberNavigatorController
@Composable
fun App() {
val graph = remember { appGraph }
val controller = rememberNavigatorController(graph)
NavigationMapScaffold(controller = controller) {
NavigatorHost(
controller = controller,
modifier = Modifier.fillMaxSize(),
)
}
}Run the app and tap the floating button in the bottom-right corner to open the full-screen navigation map.
┌─────────────────────────────────────────────────────────┐
│ navGraph { screen(...); edge(...) } → NavGraph │
│ (single source of truth) │
└────────────────────┬────────────────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
NavigatorController NavigationMap
(back stack, navigate) (visual graph UI)
│
▼
NavigatorHost
(renders current screen)
| Concept | Role |
|---|---|
NavGraph |
Immutable description of all screens and declared edges |
NavigatorController |
Manages the back stack and records runtime transitions |
NavigatorHost |
Renders the current screen with fade animation; handles system back |
NavigationMap |
Draws the interactive graph canvas |
NavigationMapScaffold |
Wraps your app and adds a FAB to open the map |
Creates a NavGraph. The start route must match one of the declared screens.
val graph = navGraph(start = "home") {
// screens and edges here
}Declares a destination.
| Parameter | Type | Default | Description |
|---|---|---|---|
route |
String |
— | Unique identifier used in navigate() |
label |
String |
route |
Human-readable name shown on the map node |
group |
String? |
null |
Optional grouping label (e.g. "Auth", "Shop") |
icon |
ImageVector? |
null |
Optional icon on the map node |
content |
@Composable ScreenScope.() -> Unit |
— | The screen UI |
Declares a static transition. Edges declared here appear on the map immediately, even before the
user navigates that path. Runtime navigate() calls also create edges automatically.
edge(from = "home", to = "settings", label = "Open settings")Obtained via rememberNavigatorController(graph). Survives configuration changes.
// Navigate forward
navigator.navigate("detail")
// Avoid duplicate top entry
navigator.navigate("detail", launchSingleTop = true)
// Reset stack to [start, route] — useful after login / checkout
navigator.navigate("home", popUpToStart = true)
// Go back
navigator.popBackStack() // returns false if already at root
// Pop until a specific screen is on top
navigator.popTo("home") // inclusive = false (default)
navigator.popTo("home", inclusive = true)
// Clear everything back to the start destination
navigator.navigateToRoot()
// Read state
controller.currentRoute // top of back stack
controller.backStack // full stack as List<String>
controller.canNavigateBack // true when stack size > 1
controller.edges // declared + runtime-observed edges
controller.observedEdges // only runtime-discovered edgesNavigatorHost(
controller = controller,
modifier = Modifier.fillMaxSize(),
handleSystemBack = true, // wire Android back button (default: true)
)For deeply nested composables that should navigate without callback threading:
@Composable
fun DeepChildButton() {
val navigator = LocalNavigator.current
Button(onClick = { navigator.navigate("settings") }) {
Text("Settings")
}
}LocalNavigator is provided automatically inside NavigatorHost.
NavigationMapScaffold(
controller = controller,
enabled = BuildConfig.DEBUG, // only show FAB in debug builds
) {
NavigatorHost(controller)
}NavigationMap(
controller = controller,
modifier = Modifier.fillMaxSize(),
style = NavigationMapStyle(
nodeWidth = 180.dp,
nodeHeight = 90.dp,
horizontalGap = 100.dp,
),
onNodeClick = { route ->
controller.navigate(route, launchSingleTop = true)
},
)var showMap by remember { mutableStateOf(false) }
if (showMap) {
NavigationMapDialog(
controller = controller,
onDismiss = { showMap = false },
)
}| Color | Meaning |
|---|---|
| Primary | Current screen |
| Secondary | Screen in the back stack |
| Surface variant | Reachable from the start destination |
| Error | Unreachable / orphan screen (no path from start) |
| Gesture | Action |
|---|---|
| Drag | Pan the canvas |
| Pinch | Zoom in / out |
| Tap a node | Navigate to that screen |
| Style | Meaning |
|---|---|
| Solid arrow | Declared in the DSL via edge(...) |
| Dashed arrow | Discovered at runtime from a navigate() call |
| Bold arrow | Part of the current back-stack path |
Generate a versionable diagram for documentation or pull requests:
import com.majid2851.navigator.export.toMermaid
val diagram = graph.toMermaid(controller.observedEdges)
println(diagram)Output example:
graph LR
n0([Home])
n1[Profile]
n0 -->|Open profile| n1
n1 -.-> n2[Settings]
- Solid arrows (
-->) = declared edges - Dashed arrows (
-.->) = runtime-observed edges - Rounded nodes (
([...])) = start destination
Paste the output into any Mermaid renderer or a GitHub markdown file.
See the :sample module for a full working app with 10 screens across three groups
(Main, Account, Shop), declared edges, orphan detection, and the map scaffold.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
val graph = remember { buildAppGraph() }
val controller = rememberNavigatorController(graph)
NavigationMapScaffold(controller = controller) {
NavigatorHost(controller, Modifier.fillMaxSize())
}
}
}
}
}
private fun buildAppGraph() = navGraph(start = "home") {
screen("home", label = "Home", group = "Main", icon = Icons.Filled.Home) {
Button(onClick = { navigator.navigate("catalog") }) { Text("Shop") }
Button(onClick = { navigator.navigate("profile") }) { Text("Profile") }
}
screen("catalog", label = "Catalog", group = "Shop") {
Button(onClick = { navigator.navigate("product") }) { Text("Product") }
OutlinedButton(onClick = { navigator.popBackStack() }) { Text("Back") }
}
screen("product", label = "Product", group = "Shop") {
OutlinedButton(onClick = { navigator.popBackStack() }) { Text("Back") }
}
screen("profile", label = "Profile", group = "Account") {
OutlinedButton(onClick = { navigator.popBackStack() }) { Text("Back") }
}
edge(from = "home", to = "catalog", label = "Browse")
edge(from = "catalog", to = "product", label = "View item")
}-
Keep the graph at the top level — define it once in a
remember { }block or a dedicated file, not inside individual screens. -
Use
groupto organize complex apps — groups appear as labels on map nodes and help you visually cluster related screens. -
Declare edges for important flows — runtime discovery works, but declaring
edge(...)makes the map complete from the first launch. -
Gate the map behind
BuildConfig.DEBUG— passenabled = BuildConfig.DEBUGtoNavigationMapScaffoldso the FAB does not appear in release builds. -
Extract screen composables — keep
screen { }blocks thin; put UI in separate@Composablefunctions for readability. -
Use
LocalNavigatorin deep trees — avoid passingonNavigatecallbacks through many layers.
# Build the demo app
./gradlew :sample:assembleDebug
# Build the library AAR
./gradlew :navigator:assembleRelease
# Publish to local Maven
./gradlew :navigator:publishReleasePublicationToMavenLocalOpen the project in Android Studio, sync Gradle, and run the sample configuration on a device
or emulator.
| Approach | Static map in IDE | Live map in app | Code generation | Works with existing NavHost |
|---|---|---|---|---|
| Compose Navigator | — | ✅ | — | — (replaces navigation) |
| Navigation Component XML | ✅ | — | — | N/A (Fragments) |
| compose-nav-graph | ✅ | — | ✅ (KSP) | ✅ (annotate only) |
Compose Navigator is the right choice when you want developers to see and interact with the navigation map inside the running app without adding build-time tooling.
| Package | Key symbols |
|---|---|
com.majid2851.navigator.dsl |
navGraph, NavGraphBuilder |
com.majid2851.navigator.model |
NavGraph, Destination, Edge |
com.majid2851.navigator.runtime |
NavigatorController, NavigatorHost, ScreenScope, LocalNavigator, rememberNavigatorController |
com.majid2851.navigator.map |
NavigationMap, NavigationMapDialog, NavigationMapScaffold, NavigationMapStyle |
com.majid2851.navigator.export |
NavGraph.toMermaid() |
MIT