
State-first, reducer-driven navigation with immutable, serializable back stacks; pluggable layouts and transitions, deep-link handling, typed result passing, lifecycle-aware scopes, and automatic persistent state.
A state-first, testable navigation library for Compose / Compose Multiplatform.
Pure reducer-driven navigation + pluggable layouts, deep links, results, and lifecycle-aware scopes.
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:
Perfect for applications that need robust, scalable, and testable navigation without the complexity of over-engineered frameworks.
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
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
}kotlinx-collections-immutable for efficient structural sharingNavigationScopes uses @Volatile and copy-on-write for lock-free thread safetyContributions are welcome! Please ensure:
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
A state-first, testable navigation library for Compose / Compose Multiplatform.
Pure reducer-driven navigation + pluggable layouts, deep links, results, and lifecycle-aware scopes.
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:
Perfect for applications that need robust, scalable, and testable navigation without the complexity of over-engineered frameworks.
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
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
}kotlinx-collections-immutable for efficient structural sharingNavigationScopes uses @Volatile and copy-on-write for lock-free thread safetyContributions are welcome! Please ensure:
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.