quo-vadis
0.5.2indexedAnnotation-based, type-safe navigation library generating graph builders and typed destinations; deep-linking, predictive back gestures, shared-element transitions, independent tab backstacks, and MVI-friendly architecture.
Annotation-based, type-safe navigation library generating graph builders and typed destinations; deep-linking, predictive back gestures, shared-element transitions, independent tab backstacks, and MVI-friendly architecture.
"Quo Vadis" (Latin for "Where are you going?") - A comprehensive, type-safe navigation library for Kotlin Multiplatform and Compose Multiplatform using a tree-based navigation architecture.
Quo Vadis provides a powerful navigation solution with:
Add the library to your Kotlin Multiplatform project:
The simplest way to set up Quo Vadis with KSP:
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
// build.gradle.kts
plugins {
kotlin("multiplatform")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp") version "2.3.0"
id("io.github.jermeyyy.quo-vadis") version "0.5.2"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jermeyyy:quo-vadis-core:0.5.2")
implementation()
}
}
}
quoVadis {
modulePrefix =
}
The plugin automatically:
For more control, configure KSP manually:
Define destinations using @Stack and @Destination annotations:
@ScreenBind Composable functions to destinations:
@Composable
fun App() {
val config = GeneratedNavigationConfig
// Build the initial NavNode tree from your root destination
val initialState = remember {
config.buildNavNode(
destinationClass = HomeDestination::class,
parentKey = null
)!!
}
// Create the navigator with config
val navigator = remember {
TreeNavigator(
config = config,
initialState = initialState
)
}
NavigationHost(
navigator = navigator,
screenRegistry = config.screenRegistry
)
}
@TabsCreate bottom navigation or tab bars with independent backstacks:
Provide the tab bar UI with @TabsContainer:
@TabsContainer(MainTabs::class)
@Composable
fun MainTabsWrapper(scope: TabsContainerScope, content: @Composable () -> Unit) {
Scaffold(
bottomBar = {
NavigationBar {
scope.tabs.forEachIndexed { index, tab ->
NavigationBarItem(
selected = index == scope.activeIndex,
onClick = { scope.switchTab(index) },
icon = { Icon(tabIcon(tab.icon), tab.label) },
label = { Text(tab.label) }
)
}
}
}
) { padding ->
Box(Modifier.padding(padding)) {
content()
}
}
}
@PaneCreate responsive list-detail layouts:
@Pane(name = "catalog", backBehavior = PaneBackBehavior.PopUntilContentChange)
sealed class CatalogPane : NavDestination {
@PaneItem(role = PaneRole.PRIMARY, rootGraph = ProductListGraph::class)
List : CatalogPane()
( id: String) : CatalogPane()
}
@TransitionSpecify transition animations per destination:
Quo Vadis uses a tree-based navigation architecture where the navigation state is represented as a tree of nodes:
NavNode (root)
├── StackNode (main stack)
│ ├── ScreenNode (Home)
│ ├── ScreenNode (List)
│ ├── ScreenNode (Detail)
│ └── ScreenNode (Menu @Modal) ← draw-behind rendering
├── TabNode (bottom tabs)
│ ├── StackNode (Tab 1 stack)
│ │ └── ScreenNode
│ └── StackNode (Tab 2 stack)
│ └── ScreenNode
└── PaneNode (adaptive layout)
├── StackNode (primary)
└── StackNode (detail)
Note: Modal nodes use the same node types above.
@Modalis a rendering flag, not a new node type.
Enable beautiful shared element animations:
@Screen(HomeDestination.Article::class)
@Composable
fun ArticleScreen(
destination: HomeDestination.Article,
navigator: Navigator,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: ?
) {
(sharedTransitionScope != && animatedVisibilityScope != ) {
Image(
modifier = Modifier.quoVadisSharedElement(
key = ,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope
)
)
}
}
Key rules:
quoVadisSharedElement() for icons/imagesquoVadisSharedBounds() for text/containersQuo Vadis integrates with FlowMVI for state management. Add the optional module:
implementation("io.github.jermeyyy:quo-vadis-core-flow-mvi:0.5.2")
Create MVI containers for individual screens:
Share state across all screens within a Tab or Pane container:
val mviModule = module {
navigationContainer<ProfileContainer> { scope ->
ProfileContainer(scope)
}
sharedNavigationContainer<MainTabsContainer> { scope ->
MainTabsContainer(scope)
}
}
kotlinx-serialization-json: 1.9.0 - Deep link serializationkotlinx-coroutines: 1.10.2 - Async navigationFlowMVI: 3.2.1 - MVI integration (optional)Koin: 4.2.0-beta2 - DI support (optional)The quo-vadis-gradle-plugin simplifies KSP configuration for Kotlin Multiplatform projects.
quoVadis {
// Module prefix for generated class names
// Default: project.name converted to PascalCase
// Example: "feature-one" → "FeatureOne" → "FeatureOneNavigationConfig"
modulePrefix = "CustomPrefix"
// Use local KSP processor (for library development)
// Default: false (uses Maven Central artifact)
useLocalKsp = true
}
The KSP processor generates these classes based on your module prefix:
| Generated Class | Purpose |
|---|---|
{Prefix}NavigationConfig | Main navigation configuration object |
{Prefix}DeepLinkHandler | Deep link handling implementation |
For example, with modulePrefix = "MyApp":
MyAppNavigationConfig - Use with NavigationHostMyAppDeepLinkHandler - Handle URI-based navigationEach module can have its own navigation config that can be combined:
// In app module
val combinedConfig = AppNavigationConfig +
Feature1NavigationConfig +
Feature2NavigationConfig
NavigationHost(
navigator = navigator,
config = combinedConfig
)
The composeApp module showcases all navigation patterns:
# Android
./gradlew :composeApp:installDebug
# iOS (Apple Silicon simulator)
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
open iosApp/iosApp.xcodeproj
# Desktop
./gradlew :composeApp:run
@Test
fun `navigate to details screen`() {
val config = GeneratedNavigationConfig
val initialState = config.buildNavNode(HomeDestination::class, null)!!
// For testing, config can be passed or use defaults (NavigationConfig.Empty)
val navigator = TreeNavigator(config = config, initialState = initialState)
navigator.navigate(HomeDestination.Article(articleId = "123"))
assertEquals(
HomeDestination.Article(articleId = "123"),
navigator.currentDestination.value
)
}
# Generate API docs
./gradlew :quo-vadis-core:dokkaGenerate
open quo-vadis-core/build/dokka/html/index.html
# Full build
./gradlew clean build
# Run tests
./gradlew test
# Build library only
./gradlew :quo-vadis-core:build
# Lint check
./gradlew lint
# Android
./gradlew :composeApp:assembleDebug
./gradlew :composeApp:installDebug
# iOS
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
# Desktop
./gradlew :composeApp:run
See CONTRIBUTING.md for guidelines.
This project is licensed under the MIT License - see the LICENSE file for details.
quo-vadis-core - The navigation library (reusable, no external dependencies)quo-vadis-annotations - KSP annotations (@Stack, @Destination, @Screen, @Tabs, @Pane)quo-vadis-ksp - Code generator for zero-boilerplate navigationquo-vadis-gradle-plugin - Gradle plugin for simplified KSP configurationquo-vadis-core-flow-mvi - Optional FlowMVI integrationcomposeApp - Demo application showcasing all navigation patterns@Stack, @Tabs, and @Pane for different navigation patterns@Argument annotation with automatic deep link serialization@Tabs + @TabItem@Pane + @PaneItem@Transition annotation with preset and custom animations@ModalFakeNavigator for unit testingNavPlayground/
├── quo-vadis-core/ # Core navigation library
│ └── src/
│ ├── commonMain/ # Core navigation logic (Navigator, NavNode, TreeNavigator)
│ ├── androidMain/ # Android-specific features (predictive back)
│ └── iosMain/ # iOS-specific features (swipe back)
├── quo-vadis-annotations/ # Annotation definitions
│ └── src/commonMain/ # @Stack, @Destination, @Screen, @Tabs, @Pane, etc.
├── quo-vadis-ksp/ # KSP code generator
│ └── src/main/ # Processor implementation
├── quo-vadis-gradle-plugin/ # Gradle plugin for KSP configuration
│ └── src/main/ # Plugin implementation
├── quo-vadis-core-flow-mvi/ # FlowMVI integration (optional)
│ └── src/commonMain/ # NavigationContainer, SharedNavigationContainer
├── composeApp/ # Demo application
│ └── src/
│ ├── commonMain/ # Demo screens & examples
│ ├── androidMain/ # Android app entry point
│ └── iosMain/ # iOS app entry point
├── iosApp/ # iOS app wrapper
└── docs/
├── refactoring-plan/ # Architecture documentation
└── site/ # Documentation website
// build.gradle.kts
plugins {
kotlin("multiplatform")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp") version "2.3.0"
}
repositories {
mavenCentral()
google()
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jermeyyy:quo-vadis-core:0.5.2")
implementation("io.github.jermeyyy:quo-vadis-annotations:0.5.2")
}
}
// Configure KSP module prefix
ksp {
arg("quoVadis.modulePrefix", "MyApp")
}
}
dependencies {
// KSP code generator (all targets)
add("kspCommonMainMetadata", "io.github.jermeyyy:quo-vadis-ksp:0.5.2")
}
// Required for KMP: Register generated sources
kotlin.sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
// Required for KMP: Fix task dependencies
afterEvaluate {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (!name.startsWith("ksp") && !name.contains("Test", ignoreCase = true)) {
dependsOn("kspCommonMainKotlinMetadata")
}
}
}
// 1. Define a navigation stack with destinations
sealed class HomeDestination : NavDestination {
data object Feed : HomeDestination()
data class Article(
val articleId: String,
val showComments: Boolean = false
) : HomeDestination()
data object Settings : HomeDestination()
}
// Simple screen (data object destination)
fun FeedScreen(navigator: Navigator) {
Column {
Text("Feed")
Button(onClick = {
navigator.navigate(HomeDestination.Article(articleId = "123"))
}) {
Text("View Article")
}
}
}
// Screen with arguments (data class destination)
fun ArticleScreen(destination: HomeDestination.Article, navigator: Navigator) {
Column {
Text("Article: ${destination.articleId}")
if (destination.showComments) {
Text("Comments visible")
}
Button(onClick = { navigator.navigateBack() }) {
Text("Back")
}
}
}
// Define each tab as @TabItem + @Stack
sealed class HomeTab : NavDestination {
data object Feed : HomeTab()
data class Article( val id: String) : HomeTab()
}
sealed class ExploreTab : NavDestination {
data object Root : ExploreTab()
}
// Define the tabs container
object MainTabs
sealed class HomeDestination : NavDestination {
// Default transition
data object List : HomeDestination()
// Horizontal slide for detail screens
data class Details( val id: String) : HomeDestination()
// Vertical slide for modals
data object Filter : HomeDestination()
// Fade for overlays
data object Help : HomeDestination()
}
| Node | Purpose | Annotation |
|---|
ScreenNode | Single screen/destination | @Destination |
StackNode | Stack of screens (push/pop) | @Stack |
TabNode | Tab container with independent stacks | @Tabs |
PaneNode | Adaptive multi-pane layout | @Pane |
interface Navigator {
val state: StateFlow<NavNode>
val currentDestination: StateFlow<NavDestination?>
val canNavigateBack: StateFlow<Boolean>
// Basic navigation
fun navigate(destination: NavDestination)
fun navigateBack(): Boolean
// Advanced navigation
fun navigateAndClearTo(destination: NavDestination)
fun navigateAndReplace(destination: NavDestination)
// Pane navigation
fun navigateToPane(role: PaneRole, destination: NavDestination)
fun switchPane(role: PaneRole)
// Deep links
fun handleDeepLink(uri: String): Boolean
}
class ProfileContainer(scope: NavigationContainerScope) :
NavigationContainer<ProfileState, ProfileIntent, ProfileAction>(scope) {
override val store = store(ProfileState()) {
reduce { intent ->
when (intent) {
is ProfileIntent.LoadProfile -> loadProfile()
is ProfileIntent.NavigateToSettings -> navigator.navigate(SettingsDestination)
}
}
}
private suspend fun loadProfile() {
updateState { copy(isLoading = true) }
// Load data...
}
}
fun ProfileScreen() {
val store = rememberContainer<ProfileContainer, ProfileState, ProfileIntent, ProfileAction>()
with(store) {
val state by subscribe()
// Render UI
Button(onClick = { intent(ProfileIntent.LoadProfile) }) {
Text("Load")
}
}
}
class MainTabsContainer(scope: SharedContainerScope) :
SharedNavigationContainer<TabsState, TabsIntent, TabsAction>(scope) {
override val store = store(TabsState(badgeCount = 0)) {
reduce { intent ->
when (intent) {
is TabsIntent.IncrementBadge -> updateState { copy(badgeCount = badgeCount + 1) }
}
}
}
}
// In tabs wrapper
fun MainTabsWrapper(scope: TabsContainerScope, content: @Composable () -> Unit) {
val store = rememberSharedContainer<MainTabsContainer, TabsState, TabsIntent, TabsAction>()
CompositionLocalProvider(LocalMainTabsStore provides store) {
val state by store.subscribe()
Scaffold(
bottomBar = { TabBar(badgeCount = state.badgeCount) }
) {
content()
}
}
}
// Child screens can access the shared store
fun HomeScreen() {
val tabsStore = LocalMainTabsStore.current
Button(onClick = { tabsStore?.intent(TabsIntent.IncrementBadge) }) {
Text("Update Badge")
}
}
| Feature | Description |
|---|
| Auto KSP Setup | Configures kspCommonMainMetadata dependency automatically |
| Module Prefix | Generates class names like MyAppNavigationConfig |
| Source Registration | Registers generated source directories for KMP |
| Task Dependencies | Ensures KSP runs before compilation |
| Platform | Target | Status | Features |
|---|
| Android | androidLibrary | ✅ Production | Predictive back, deep links, system integration |
| iOS | iosArm64 iosSimulatorArm64 iosX64 | ✅ Production | Swipe back, universal links |
| Desktop | jvm("desktop") | ✅ Production | Native windows (macOS, Windows, Linux) |
@Tabs@Stack@PaneSurfaced from shared tags and platforms — no rankings paid for.