A state-first, testable navigation library for Compose / Compose Multiplatform.
Pure reducer-driven navigation + pluggable layouts, deep links, results, and lifecycle-aware scopes.
KompassUsageOverviewScene.webm
Kompass is the next-generation navigation library designed from the ground up for Jetpack Compose. Unlike traditional navigation approaches, Kompass embraces functional programming principles and reactive architecture patterns:
- Pure State Management - Navigation state is immutable, serializable, and completely decoupled from UI logic. The entire navigation system is built on predictable, deterministic state transitions.
- Reducer Pattern - All navigation rules are side-effect free, making the navigation core trivial to test without mocking frameworks or instrumentation.
- Multi-Graph Architecture - Organize large applications across multiple modular navigation graphs with independent layouts and transitions.
- Lifecycle-Aware Scopes - Built-in scope management provides ViewModel-like instance storage with automatic cleanup and memory leak prevention.
- Deep Linking Made Simple - Extensible deep link handlers convert URIs into navigation commands with type-safe argument parsing.
- Result Passing - Deliver typed results between destinations without tight coupling or callback hell.
- Persistent State - Automatic serialization and restoration across configuration changes, process death, and app relaunches.
- Customizable Layouts & Transitions - Per-graph scene layouts support any composition pattern: single-stack, master-detail, split-screen, or custom multi-pane designs.
Perfect for applications that need robust, scalable, and testable navigation without the complexity of over-engineered frameworks.
- Key Features
- Architecture
- Installation
- Quick Start
- Navigation Commands
- Navigation Scopes
- Navigation Results
- Custom Layouts & Transitions
- Deep Linking
- State Serialization
- Testing
- Configuration & Customization
- Performance Considerations
- Thread Safety
- Contributing
- Resources
- Pure State Management - Navigation state is immutable and serializable, separate from UI logic
- Testable Reducer Pattern - All navigation rules are deterministic and side-effect free
- Pluggable Layouts & Transitions - Customize screen transitions and multi-pane layouts per graph
- Multi-Graph Support - Organize destinations across multiple navigation graphs
- Navigation Scopes - Lifecycle-aware instance management (ViewModel-like) with automatic cleanup
- Deep Linking - Built-in deep link support with extensible handlers
- Result Passing - Deliver navigation results between destinations
- Persistent State - Automatic serialization and restoration across configuration changes
Kompass follows a clean separation of concerns:
Navigation Logic
↓
NavigationHandler (pure reducer: State + Command → State)
↓
NavigationState (immutable back stack)
↓
NavController (facade & effects)
↓
KompassNavigationHost (renders via NavigationGraph)
↓
Screen Content
- NavigationState - Immutable representation of the back stack
- NavigationHandler - Pure reducer applying navigation commands
- NavController - Public API for performing navigation
- KompassNavigationHost - Root composable orchestrating rendering
- NavigationGraph - Maps destinations to UI content
- NavigationScopes - Thread-safe lifecycle-aware instance storage
- BackStackEntry - Represents a single stack entry with destination, args, and scope
Add to your build.gradle.kts:
repositories {
mavenCentral()
}
dependencies {
implementation("com.tekmoon:kompass:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}sealed interface MainDestination : Destination {
data object Home : MainDestination {
override val id: String = "home"
}
data object Profile : MainDestination {
override val id: String = "profile"
}
data object Settings : MainDestination {
override val id: String = "settings"
}
}class MainNavigationGraph : NavigationGraph {
override fun canResolveDestination(destinationId: String): Boolean =
destinationId in setOf("home", "profile", "settings")
override fun resolveDestination(
destinationId: String,
args: String?
): Destination = when (destinationId) {
"home" -> MainDestination.Home
"profile" -> MainDestination.Profile
"settings" -> MainDestination.Settings
else -> error("Unknown destination: $destinationId")
}
@Composable
override fun Content(
entry: BackStackEntry,
destination: Destination,
navController: NavController
) {
when (destination) {
is MainDestination.Home -> HomeScreen(navController)
is MainDestination.Profile -> ProfileScreen(navController)
is MainDestination.Settings -> SettingsScreen(navController)
}
}
}@Composable
fun AppNavigation() {
val navController = rememberNavController(
startDestination = MainDestination.Home
)
KompassNavigationHost(
navController = navController,
graphs = persistentListOf(MainNavigationGraph())
)
}@Composable
fun HomeScreen(navController: NavController) {
Button(
onClick = {
navController.navigate(
entry = BackStackEntry(
destinationId = "profile",
scopeId = newScope()
)
)
}
) {
Text("Go to Profile")
}
}Push a new destination onto the back stack:
navController.navigate(
entry = BackStackEntry(
destinationId = "profile",
args = """{"userId":"123"}""",
scopeId = newScope()
),
clearBackStack = false, // Clear entire stack
popUpTo = "home", // Pop up to destination
popUpToInclusive = false, // Include the destination in pop
reuseIfExists = false // Reuse existing entry
)Remove one or more entries from the back stack:
// Pop single entry
navController.pop()
// Pop with result
navController.pop(result = ProfileResult(userId = "123"))
// Pop multiple entries
navController.pop(count = 2)
// Pop until destination
navController.pop(popUntil = "home")Replace the entire back stack with a single entry:
navController.replaceRoot(
entry = BackStackEntry(
destinationId = "home",
scopeId = newScope()
)
)Navigation Scopes provide lifecycle-aware instance storage similar to ViewModels:
@Composable
fun ProfileScreen(navController: NavController, entry: BackStackEntry) {
val viewModel = rememberScoped<ProfileViewModel>(
scopeId = entry.scopeId,
factory = { ProfileViewModel() },
onCleared = { it.close() }
)
// ViewModel survives recomposition but is cleared when entry is popped
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
}Default Scope - Shared state across multiple navigations to same destination:
val entry = BackStackEntry(
destinationId = "profile",
scopeId = destination.defaultScope() // "entry:profile"
)Unique Scope - Isolated state for each navigation:
val entry = BackStackEntry(
destinationId = "profile",
scopeId = newScope() // "entry:{randomUUID}"
)Pass data between destinations using results:
// Send result when popping
navController.pop(
result = ProfileResult(userId = "123"),
count = 1
)
// Receive result in destination
@Composable
fun HomeScreen(navController: NavController, entry: BackStackEntry) {
val result = entry.results["profile_result"] as? ProfileResult
LaunchedEffect(result) {
if (result != null) {
// Handle result
}
}
}Customize screen transitions and multi-pane layouts:
class MainNavigationGraph : NavigationGraph {
override val sceneLayout: SceneLayout = object : SceneLayout {
@Composable
override fun Render(
backStack: ImmutableList<BackStackEntry>,
resolve: (BackStackEntry) -> Pair<NavigationGraph, Destination>,
navController: NavController,
direction: NavDirection
) {
AnimatedContent(
targetState = backStack.last(),
transitionSpec = {
slideInHorizontally() togetherWith slideOutHorizontally()
}
) { entry ->
val (graph, destination) = resolve(entry)
graph.Content(entry, destination, navController)
}
}
}
override val sceneTransition: SceneTransition? = null
}For tablet layouts with master-detail patterns:
override val sceneLayout: SceneLayout = object : SceneLayout {
@Composable
override fun Render(
backStack: ImmutableList<BackStackEntry>,
resolve: (BackStackEntry) -> Pair<NavigationGraph, Destination>,
navController: NavController,
direction: NavDirection
) {
Row {
// Master pane (static)
Box(modifier = Modifier.weight(1f)) {
val masterEntry = backStack.first()
val (graph, destination) = resolve(masterEntry)
graph.Content(masterEntry, destination, navController)
}
// Detail pane (animated)
AnimatedContent(
targetState = backStack.last(),
modifier = Modifier.weight(1f),
label = "DetailPane"
) { entry ->
val (graph, destination) = resolve(entry)
graph.Content(entry, destination, navController)
}
}
}
}Resolve deep link URIs to navigation commands:
interface DeepLinkHandler {
fun canHandle(uri: String): Boolean
fun resolve(uri: String): List<NavigationCommand>?
}
class ProfileDeepLinkHandler : DeepLinkHandler {
override fun canHandle(uri: String): Boolean = uri.startsWith("app://profile/")
override fun resolve(uri: String): List<NavigationCommand>? {
val userId = uri.removePrefix("app://profile/")
return listOf(
NavigationCommand.Navigate(
entry = BackStackEntry(
destinationId = "profile",
args = """{"userId":"$userId"}""",
scopeId = newScope()
)
)
)
}
}
// Apply deep link
val success = navController.applyDeepLink("app://profile/user123")Navigation state is automatically serialized and restored:
@Composable
fun rememberNavController(
initialState: NavigationState,
serializersModule: SerializersModule = SerializersModule {},
deepLinkUri: String? = null,
deepLinkHandlers: ImmutableList<DeepLinkHandler> = persistentListOf()
): NavController {
// State is saved via rememberSaveable and restored on configuration changes
}Since navigation logic is pure and deterministic, testing is straightforward:
@Test
fun testNavigateCommand() {
val handler = NavigationHandler()
val initialState = defaultNavigationState(
BackStackEntry(
destinationId = "home",
scopeId = NavigationScopeId("home")
)
)
val command = NavigationCommand.Navigate(
entry = BackStackEntry(
destinationId = "profile",
scopeId = newScope()
)
)
val newState = handler.reduce(initialState, command)
assertEquals(2, newState.backStack.size)
assertEquals("profile", newState.backStack.last().destinationId)
}
@Test
fun testPopCommand() {
val handler = NavigationHandler()
val state = NavigationState(
backStack = persistentListOf(
BackStackEntry("home", scopeId = NavigationScopeId("home")),
BackStackEntry("profile", scopeId = newScope())
).toImmutableList()
)
val newState = handler.reduce(state, NavigationCommand.Pop())
assertEquals(1, newState.backStack.size)
assertEquals("home", newState.backStack.last().destinationId)
}Register custom serializers for destination arguments:
val serializersModule = SerializersModule {
polymorphic(NavigationResult::class) {
subclass(ProfileResult::class)
subclass(SettingsResult::class)
}
}
val navController = rememberNavController(
startDestination = MainDestination.Home,
serializersModule = serializersModule
)Extend NavigationScope for specialized instance management:
class CustomNavigationScope(id: NavigationScopeId) : NavigationScope(id) {
// Add custom behavior
}- Immutable Collections - Uses
kotlinx-collections-immutablefor efficient structural sharing - Lazy Graph Resolution - Destinations are only resolved when rendered
- Efficient Recomposition - State changes only trigger recomposition of affected content
- Scope Cleanup - Scopes are automatically cleaned when entries are removed, preventing memory leaks
- Navigation state is immutable and thread-safe
NavigationScopesuses@Volatileand copy-on-write for lock-free thread safety- Safe to call from Composable and background threads
Contributions are welcome! Please ensure:
- All navigation logic remains pure and testable
- New features maintain backward compatibility
- Comprehensive tests accompany changes
- Code follows existing style and patterns
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
