
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:
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 animationsFakeNavigator 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
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.3.4"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jermeyyy:quo-vadis-core:0.3.4")
implementation("io.github.jermeyyy:quo-vadis-annotations:0.3.4")
}
}
}
// Optional: Configure the plugin
quoVadis {
// Override module prefix (defaults to project.name in PascalCase)
modulePrefix = "MyApp"
}The plugin automatically:
For more control, configure KSP manually:
// 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.3.4")
implementation("io.github.jermeyyy:quo-vadis-annotations:0.3.4")
}
}
// Configure KSP module prefix
ksp {
arg("quoVadis.modulePrefix", "MyApp")
}
}
dependencies {
// KSP code generator (all targets)
add("kspCommonMainMetadata", "io.github.jermeyyy:quo-vadis-ksp:0.3.4")
}
// 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")
}
}
}Define destinations using @Stack and @Destination annotations:
// 1. Define a navigation stack with destinations
@Stack(name = "home", startDestination = Feed::class)
sealed class HomeDestination : NavDestination {
@Destination(route = "home/feed")
data object Feed : HomeDestination()
@Destination(route = "home/article/{articleId}")
data class Article(
@Argument val articleId: String,
@Argument(optional = true) val showComments: Boolean = false
) : HomeDestination()
@Destination(route = "home/settings")
data object Settings : HomeDestination()
}Bind Composable functions to destinations:
// Simple screen (data object destination)
@Screen(HomeDestination.Feed::class)
@Composable
fun FeedScreen(navigator: Navigator) {
Column {
Text("Feed")
Button(onClick = {
navigator.navigate(HomeDestination.Article(articleId = "123"))
}) {
Text("View Article")
}
}
}
// Screen with arguments (data class destination)
@Screen(HomeDestination.Article::class)
@Composable
fun ArticleScreen(destination: HomeDestination.Article, navigator: Navigator) {
Column {
Text("Article: ${destination.articleId}")
if (destination.showComments) {
Text("Comments visible")
}
Button(onClick = { navigator.navigateBack() }) {
Text("Back")
}
}
}@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
)
}
// Render the navigation tree - config read from navigator
NavigationHost(
navigator = navigator,
screenRegistry = config.screenRegistry
)
}Create bottom navigation or tab bars with independent backstacks:
// Define each tab as @TabItem + @Stack
@TabItem(label = "Home", icon = "home")
@Stack(name = "homeStack", startDestination = HomeTab.Feed::class)
sealed class HomeTab : NavDestination {
@Destination(route = "home/feed")
data object Feed : HomeTab()
@Destination(route = "home/article/{id}")
data class Article(@Argument val id: String) : HomeTab()
}
@TabItem(label = "Explore", icon = "explore")
@Stack(name = "exploreStack", startDestination = ExploreTab.Root::class)
sealed class ExploreTab : NavDestination {
@Destination(route = "explore/root")
data object Root : ExploreTab()
}
// Define the tabs container
@Tabs(
name = "mainTabs",
initialTab = HomeTab::class,
items = [HomeTab::class, ExploreTab::class]
)
object MainTabsProvide 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()
}
}
}Create responsive list-detail layouts:
@Pane(name = "catalog", backBehavior = PaneBackBehavior.PopUntilContentChange)
sealed class CatalogPane : NavDestination {
@PaneItem(role = PaneRole.PRIMARY, rootGraph = ProductListGraph::class)
@Destination(route = "catalog/list")
data object List : CatalogPane()
@PaneItem(
role = PaneRole.SECONDARY,
adaptStrategy = AdaptStrategy.OVERLAY,
rootGraph = ProductDetailGraph::class
)
@Destination(route = "catalog/detail/{id}")
data class Detail(@Argument val id: String) : CatalogPane()
}Specify transition animations per destination:
@Stack(name = "home", startDestination = "List")
sealed class HomeDestination : NavDestination {
// Default transition
@Destination(route = "list")
data object List : HomeDestination()
// Horizontal slide for detail screens
@Transition(type = TransitionType.SlideHorizontal)
@Destination(route = "details/{id}")
data class Details(@Argument val id: String) : HomeDestination()
// Vertical slide for modals
@Transition(type = TransitionType.SlideVertical)
@Destination(route = "filter")
data object Filter : HomeDestination()
// Fade for overlays
@Transition(type = TransitionType.Fade)
@Destination(route = "help")
data object Help : HomeDestination()
}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)
โโโ TabNode (bottom tabs)
โ โโโ StackNode (Tab 1 stack)
โ โ โโโ ScreenNode
โ โโโ StackNode (Tab 2 stack)
โ โโโ ScreenNode
โโโ PaneNode (adaptive layout)
โโโ StackNode (primary)
โโโ StackNode (detail)
| 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
}Enable beautiful shared element animations:
@Screen(HomeDestination.Article::class)
@Composable
fun ArticleScreen(
destination: HomeDestination.Article,
navigator: Navigator,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?
) {
// Use shared elements when scopes are available
if (sharedTransitionScope != null && animatedVisibilityScope != null) {
Image(
modifier = Modifier.quoVadisSharedElement(
key = "article-image-${destination.articleId}",
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.3.4")Create MVI containers for individual screens:
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...
}
}
@Composable
fun ProfileScreen() {
val store = rememberContainer<ProfileContainer, ProfileState, ProfileIntent, ProfileAction>()
with(store) {
val state by subscribe()
// Render UI
Button(onClick = { intent(ProfileIntent.LoadProfile) }) {
Text("Load")
}
}
}Share state across all screens within a Tab or Pane container:
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
@TabsContainer(MainTabs::class)
@Composable
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
@Composable
fun HomeScreen() {
val tabsStore = LocalMainTabsStore.current
Button(onClick = { tabsStore?.intent(TabsIntent.IncrementBadge) }) {
Text("Update Badge")
}
}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.
| 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 |
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 NavigationHost
MyAppDeepLinkHandler - 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
)| Platform | Target | Status | Features |
|---|---|---|---|
| Android | androidLibrary |
โ Production | Predictive back, deep links, system integration |
| iOS |
iosArm64 iosSimulatorArm64 iosX64
|
โ Production | Swipe back, universal links |
| JavaScript | js(IR) |
โ Production | Browser history, Canvas rendering |
| WebAssembly | wasmJs |
โ Production | Near-native performance |
| Desktop | jvm("desktop") |
โ Production | Native windows (macOS, Windows, Linux) |
The composeApp module showcases all navigation patterns:
@Tabs
@Stack
@Pane
# Android
./gradlew :composeApp:installDebug
# iOS (Apple Silicon simulator)
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
open iosApp/iosApp.xcodeproj
# Web (JavaScript)
./gradlew :composeApp:jsBrowserDevelopmentRun --continuous
# Web (WebAssembly)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun --continuous
# 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
# Web
./gradlew :composeApp:jsBrowserDevelopmentRun
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Desktop
./gradlew :composeApp:runSee CONTRIBUTING.md for guidelines.
This project is licensed under the MIT License - see the LICENSE file for details.
"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:
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 animationsFakeNavigator 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
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.3.4"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jermeyyy:quo-vadis-core:0.3.4")
implementation("io.github.jermeyyy:quo-vadis-annotations:0.3.4")
}
}
}
// Optional: Configure the plugin
quoVadis {
// Override module prefix (defaults to project.name in PascalCase)
modulePrefix = "MyApp"
}The plugin automatically:
For more control, configure KSP manually:
// 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.3.4")
implementation("io.github.jermeyyy:quo-vadis-annotations:0.3.4")
}
}
// Configure KSP module prefix
ksp {
arg("quoVadis.modulePrefix", "MyApp")
}
}
dependencies {
// KSP code generator (all targets)
add("kspCommonMainMetadata", "io.github.jermeyyy:quo-vadis-ksp:0.3.4")
}
// 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")
}
}
}Define destinations using @Stack and @Destination annotations:
// 1. Define a navigation stack with destinations
@Stack(name = "home", startDestination = Feed::class)
sealed class HomeDestination : NavDestination {
@Destination(route = "home/feed")
data object Feed : HomeDestination()
@Destination(route = "home/article/{articleId}")
data class Article(
@Argument val articleId: String,
@Argument(optional = true) val showComments: Boolean = false
) : HomeDestination()
@Destination(route = "home/settings")
data object Settings : HomeDestination()
}Bind Composable functions to destinations:
// Simple screen (data object destination)
@Screen(HomeDestination.Feed::class)
@Composable
fun FeedScreen(navigator: Navigator) {
Column {
Text("Feed")
Button(onClick = {
navigator.navigate(HomeDestination.Article(articleId = "123"))
}) {
Text("View Article")
}
}
}
// Screen with arguments (data class destination)
@Screen(HomeDestination.Article::class)
@Composable
fun ArticleScreen(destination: HomeDestination.Article, navigator: Navigator) {
Column {
Text("Article: ${destination.articleId}")
if (destination.showComments) {
Text("Comments visible")
}
Button(onClick = { navigator.navigateBack() }) {
Text("Back")
}
}
}@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
)
}
// Render the navigation tree - config read from navigator
NavigationHost(
navigator = navigator,
screenRegistry = config.screenRegistry
)
}Create bottom navigation or tab bars with independent backstacks:
// Define each tab as @TabItem + @Stack
@TabItem(label = "Home", icon = "home")
@Stack(name = "homeStack", startDestination = HomeTab.Feed::class)
sealed class HomeTab : NavDestination {
@Destination(route = "home/feed")
data object Feed : HomeTab()
@Destination(route = "home/article/{id}")
data class Article(@Argument val id: String) : HomeTab()
}
@TabItem(label = "Explore", icon = "explore")
@Stack(name = "exploreStack", startDestination = ExploreTab.Root::class)
sealed class ExploreTab : NavDestination {
@Destination(route = "explore/root")
data object Root : ExploreTab()
}
// Define the tabs container
@Tabs(
name = "mainTabs",
initialTab = HomeTab::class,
items = [HomeTab::class, ExploreTab::class]
)
object MainTabsProvide 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()
}
}
}Create responsive list-detail layouts:
@Pane(name = "catalog", backBehavior = PaneBackBehavior.PopUntilContentChange)
sealed class CatalogPane : NavDestination {
@PaneItem(role = PaneRole.PRIMARY, rootGraph = ProductListGraph::class)
@Destination(route = "catalog/list")
data object List : CatalogPane()
@PaneItem(
role = PaneRole.SECONDARY,
adaptStrategy = AdaptStrategy.OVERLAY,
rootGraph = ProductDetailGraph::class
)
@Destination(route = "catalog/detail/{id}")
data class Detail(@Argument val id: String) : CatalogPane()
}Specify transition animations per destination:
@Stack(name = "home", startDestination = "List")
sealed class HomeDestination : NavDestination {
// Default transition
@Destination(route = "list")
data object List : HomeDestination()
// Horizontal slide for detail screens
@Transition(type = TransitionType.SlideHorizontal)
@Destination(route = "details/{id}")
data class Details(@Argument val id: String) : HomeDestination()
// Vertical slide for modals
@Transition(type = TransitionType.SlideVertical)
@Destination(route = "filter")
data object Filter : HomeDestination()
// Fade for overlays
@Transition(type = TransitionType.Fade)
@Destination(route = "help")
data object Help : HomeDestination()
}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)
โโโ TabNode (bottom tabs)
โ โโโ StackNode (Tab 1 stack)
โ โ โโโ ScreenNode
โ โโโ StackNode (Tab 2 stack)
โ โโโ ScreenNode
โโโ PaneNode (adaptive layout)
โโโ StackNode (primary)
โโโ StackNode (detail)
| 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
}Enable beautiful shared element animations:
@Screen(HomeDestination.Article::class)
@Composable
fun ArticleScreen(
destination: HomeDestination.Article,
navigator: Navigator,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?
) {
// Use shared elements when scopes are available
if (sharedTransitionScope != null && animatedVisibilityScope != null) {
Image(
modifier = Modifier.quoVadisSharedElement(
key = "article-image-${destination.articleId}",
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.3.4")Create MVI containers for individual screens:
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...
}
}
@Composable
fun ProfileScreen() {
val store = rememberContainer<ProfileContainer, ProfileState, ProfileIntent, ProfileAction>()
with(store) {
val state by subscribe()
// Render UI
Button(onClick = { intent(ProfileIntent.LoadProfile) }) {
Text("Load")
}
}
}Share state across all screens within a Tab or Pane container:
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
@TabsContainer(MainTabs::class)
@Composable
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
@Composable
fun HomeScreen() {
val tabsStore = LocalMainTabsStore.current
Button(onClick = { tabsStore?.intent(TabsIntent.IncrementBadge) }) {
Text("Update Badge")
}
}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.
| 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 |
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 NavigationHost
MyAppDeepLinkHandler - 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
)| Platform | Target | Status | Features |
|---|---|---|---|
| Android | androidLibrary |
โ Production | Predictive back, deep links, system integration |
| iOS |
iosArm64 iosSimulatorArm64 iosX64
|
โ Production | Swipe back, universal links |
| JavaScript | js(IR) |
โ Production | Browser history, Canvas rendering |
| WebAssembly | wasmJs |
โ Production | Near-native performance |
| Desktop | jvm("desktop") |
โ Production | Native windows (macOS, Windows, Linux) |
The composeApp module showcases all navigation patterns:
@Tabs
@Stack
@Pane
# Android
./gradlew :composeApp:installDebug
# iOS (Apple Silicon simulator)
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
open iosApp/iosApp.xcodeproj
# Web (JavaScript)
./gradlew :composeApp:jsBrowserDevelopmentRun --continuous
# Web (WebAssembly)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun --continuous
# 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
# Web
./gradlew :composeApp:jsBrowserDevelopmentRun
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Desktop
./gradlew :composeApp:runSee CONTRIBUTING.md for guidelines.
This project is licensed under the MIT License - see the LICENSE file for details.