
Facilitates seamless navigation in applications by offering features like nested navigation, customizable transitions, deep-link support, and flexible data passing between screens without code generation.
https://github.com/user-attachments/assets/daa73bec-47f6-42bf-b38f-6378793540ee
Add the dependency below to your module's build.gradle.kts file:
| Module | Version |
|---|---|
| tiamat | |
| tiamat-destinations | |
| tiamat-destinations (plugin) |
Migration Tiamat 1.* -> Tiamat 2.*
sourceSets {
commonMain.dependencies {
implementation("io.github.composegears:tiamat:$version")
}
}plugins {
// Tiamat-destinations kotlin compiler plugin
id("io.github.composegears.tiamat.destinations.compiler") version "$version"
}
sourceSets {
commonMain.dependencies {
// InstallIn annotations and Graph base class
implementation("io.github.composegears:tiamat-destinations:$version")
}
}
Use same dependencies in the dependencies { ... } section
val Screen by navDestination<Args> {
// content
} val navController = rememberNavController(
key = "Some nav controller",
startDestination = Screen,
)Navigation(
navController = navController,
destinations = arrayOf(
Screen,
AnotherScreen,
// ...,
),
modifier = Modifier.fillMaxSize(),
contentTransformProvider = { navigationPlatformDefault(it) }
)val Screen by navDestination<Args> {
val navController = navController()
Column {
Text("Screen")
Button(onClick = { navController.navigate(AnotherScreen) }){
Text("Navigate")
}
}
}see example: App.kt
The screens in Tiamat should be an entities (similar to composable functions)
the Args generic define the type of data, acceptable by screen as input parameters in the NavController:navigate fun
val RootScreen by navDestination<Args> {
// ...
val nc = navController()
// ...
nc.navigate(DataScreen, DataScreenArgs(1))
// ...
}
data class DataScreenArgs(val t: Int)
val DataScreen by navDestination<DataScreenArgs> {
val args = navArgs()
}
The screen content scoped in NavDestinationScope<Args>
The scope provides a number of composable functions:
Some examples:
navController - provides current NavController to navigate back/furthernavArgs - the arguments provided to this screen by NavControllr:navigate(screen, args) funnavArgsOrNull - same as navArgs but provides null if there is no data passed or if it was lostfreeArgs - free type arguments, useful to store metadata or pass deeplink infoclearFreeArgs - clear free type arguments (eg: clear handled deeplink info)navResult - provide the data passed to NavControllr:back(screen, navResult) as resultclearNavResult - clear passed nav result (eg: you want to show notification base on result and clear it not to re-show)rememberViewModel - create or provide view model scoped(linked) to current screenYou can create NavController using one of rememberNavController functions:
fun rememberNavController(
//...
)and display as part of any composable function
@Composable
fun Content() {
val navController = rememberNavController( /*... */)
Navigation(
navController = navController,
destinations = arrayOf(
// ...
),
modifier = Modifier.fillMaxSize()
)
}NavController will keep the screens data, view models, and states during navigation
[!IMPORTANT] The data may be cleared by system (eg: Android may clear memory)
fun rememberNavController( // ... saveable: Boolean? = null, // ... )
saveableproperty of remembered nav controller will indicate if we need to save/restore state or no
You can attach an extension to any destination
There is 2 extension types: with and without content
The content-extension allows to process content before destination body and after by specifying type (Overlay, Underlay)
Here is simple tracker extension:
// define extension
class AnalyticsExt(private val name: String) : ContentExtension<Any?>() {
@Composable
override fun NavDestinationScope<out Any?>.Content() {
val entry = navEntry()
LaunchedEffect(Unit) {
val service = /*...*/ // receive tracker
service.trackScreen(screenName = name, destination = entry.destination.name)
}
}
}
// apply ext to screen
val SomeScreen by navDestination<Args>(
AnalyticsExt("SomeScreen")
) {
// screen content
}
[!IMPORTANT] Only 'Savable' types of params & args will be available to use within
saveablenav controllerseg: Android - Parcelable + any bundlable primitives
[!IMPORTANT]
Type checking has run into a recursive problem. Easiest workaround: specify types of your declarations explicitlyide error.val SomeScreen1 by navDestination<Args> { val navController = navController() Button( onClick = { navController.navigate(SomeScreen2) }, // << error here content = { Text("goScreen2") } ) } val SomeScreen2 by navDestination<Args> { val navController = navController() Button( onClick = { navController.navigate(SomeScreen1) }, // << or here content = { Text("goScreen2") } ) }Appears when it is circular initialization happen (Screen1 knows about Screen2 who knows about Screen1 ...)
Solution: just define types of root(any in chain) screens explicitly
val SomeScreen1: NavDestination<Unit> by navDestination { /* ... */ }
[!IMPORTANT] Why is my system back button works wired with custom back handler?
While using custom back handler do not forget 2 rules
- Always place
NavBackHandlerbeforeNavigation- use
Navigation(handleSystemBackEvent = false)flag to disable extra back handler
See the examples here
Or try them in browser (require WASM support) here
// there is 2 common ideas behind handle complex navigation
//---- idea 1 -----
// create some data/param that will be passed via free args
// each screen handle this arg and opens `next` screen
val DeeplinkScreen by navDestination<Args> {
val deeplink = freeArgs<DeeplinkData>() // take free args
val deeplinkNavController = rememberNavController(
key = "deeplinkNavController",
startDestination = ShopScreen
) {
// handle deeplink and open next screen
if (deeplink != null) {
editNavStack { _->
listOf(
ShopScreen.toNavEntry(),
CategoryScreen.toNavEntry(navArgs = deeplink.categoryId),
DetailScreen.toNavEntry(navArgs = DetailParams(deeplink.productName, deeplink.productId))
)
}
clearFreeArgs()
}
}
Navigation(/*...*/)
}
//---- idea 2 -----
// use route-api
if (deeplink != null) {
navController?.route {
element(ShopScreen)
element(CategoryScreen.toNavEntry(navArgs = deeplink.categoryId))
element(DetailScreen.toNavEntry(navArgs = DetailParams(deeplink.productName, deeplink.productId)))
}
deepLinkController.clearDeepLink()
}startDestination = null + LaunchEffect \ DisposableEffect to make start destination dynamic and see 1 frame of animation // LaunchEffect & DisposableEffect are executed on `next` frame, so you may see 1 frame of animation
// to avoid this effect use `configuration` lambda within `rememberNavController` fun
val deeplinkNavController = rememberNavController(
key = "deeplinkNavController",
startDestination = ShopScreen,
) { // executed right after being created or restored
// so you can handle initial navigation here without any animations
}
// Yep, there is 2-pane layout example. You can also create fully custom layout by using `scene` api
val nc = rememberNavController(
key = "nav controller",
startDestination = SomeDest1,
)
// using scene api
NavigationScene(
navController = nc,
destinations = arrayOf(
SomeDest1,
SomeDest2,
SomeDest3,
)
) {
// place you destinations as you want ( !!!CAUTION!!! do not render same entry twice in a frame)
AnimatedContent(
targetState = nc.currentNavEntryAsState(),
contentKey = { it?.contentKey() },
transitionSpec = { navigationFadeInOut() }
) {
// you can also draw an entries from the whole nav stack if you need (but be careful)
EntryContent(it)
}
}
Library provides a utility function TiamatPreview for previewing individual navigation destinations in Compose Preview.
[!NOTE] Preview works best for pure Compose UI code. If your destination contains ViewModels, dependency injection, or complex app logic, consider creating separate preview functions for specific UI components instead of the entire destination.
Usage:
// Define your destination
val DemoScreen by navDestination<Unit> {
Text("Demo Screen")
}
// Create preview
@Preview
@Composable
private fun DemoScreenPreview() {
TiamatPreview(destination = DemoScreen)
}For screens with arguments:
data class UserProfileArgs(val userId: String, val userName: String)
val UserProfileScreen by navDestination<UserProfileArgs> {
val args = navArgs()
Column {
Text("User: ${args.userName}")
Text("ID: ${args.userId}")
}
}
@Preview
@Composable
private fun UserProfileScreenPreview() {
TiamatPreview(
destination = UserProfileScreen,
navArgs = UserProfileArgs(userId = "123", userName = "John")
)
}For complex destinations with ViewModels or app logic:
// Instead of previewing the entire destination
val ComplexScreen by navDestination<Unit> {
val viewModel = viewModel<MyViewModel>()
val data by viewModel.data.collectAsState()
ComplexScreenContent(data = data)
}
// Create preview for the UI component
@Composable
private fun ComplexScreenContent(data: MyData) {
Column {
Text("Title: ${data.title}")
// ... rest of UI
}
}
@Preview
@Composable
private fun ComplexScreenContentPreview() {
ComplexScreenContent(
data = MyData(title = "Preview Title")
)
}Nothing specific (yet)
Tiamat overrides LocalLifecycleOwner for each destination. This makes it compatible with lifecycle-aware components
See an example of CameraX usage: CameraXLifecycleScreen.kt
Nothing specific (yet)
Android: ./gradlew sample:app-android:assembleDebug
Jvm: ./gradlew sample:app-jvm:run
Jvm + hot-reload: ./gradlew sample:app-jvm:hotRun
Web: ./gradlew :sample:app-wasm:wasmJsBrowserDevelopmentRun
iOS: run XCode project or else use KMP plugin iOS target
other commands:
check ABI: ./gradlew checkAbi
update ABI: ./gradlew updateAbi
kover html report: ./gradlew :tiamat:koverHtmlReport
print test coverage: ./gradlew :tiamat:koverLog
run detekt checks: ./gradlew detekt
Thank you for your help! ❤️
Developed by ComposeGears 2025
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
https://github.com/user-attachments/assets/daa73bec-47f6-42bf-b38f-6378793540ee
Add the dependency below to your module's build.gradle.kts file:
| Module | Version |
|---|---|
| tiamat | |
| tiamat-destinations | |
| tiamat-destinations (plugin) |
Migration Tiamat 1.* -> Tiamat 2.*
sourceSets {
commonMain.dependencies {
implementation("io.github.composegears:tiamat:$version")
}
}plugins {
// Tiamat-destinations kotlin compiler plugin
id("io.github.composegears.tiamat.destinations.compiler") version "$version"
}
sourceSets {
commonMain.dependencies {
// InstallIn annotations and Graph base class
implementation("io.github.composegears:tiamat-destinations:$version")
}
}
Use same dependencies in the dependencies { ... } section
val Screen by navDestination<Args> {
// content
} val navController = rememberNavController(
key = "Some nav controller",
startDestination = Screen,
)Navigation(
navController = navController,
destinations = arrayOf(
Screen,
AnotherScreen,
// ...,
),
modifier = Modifier.fillMaxSize(),
contentTransformProvider = { navigationPlatformDefault(it) }
)val Screen by navDestination<Args> {
val navController = navController()
Column {
Text("Screen")
Button(onClick = { navController.navigate(AnotherScreen) }){
Text("Navigate")
}
}
}see example: App.kt
The screens in Tiamat should be an entities (similar to composable functions)
the Args generic define the type of data, acceptable by screen as input parameters in the NavController:navigate fun
val RootScreen by navDestination<Args> {
// ...
val nc = navController()
// ...
nc.navigate(DataScreen, DataScreenArgs(1))
// ...
}
data class DataScreenArgs(val t: Int)
val DataScreen by navDestination<DataScreenArgs> {
val args = navArgs()
}
The screen content scoped in NavDestinationScope<Args>
The scope provides a number of composable functions:
Some examples:
navController - provides current NavController to navigate back/furthernavArgs - the arguments provided to this screen by NavControllr:navigate(screen, args) funnavArgsOrNull - same as navArgs but provides null if there is no data passed or if it was lostfreeArgs - free type arguments, useful to store metadata or pass deeplink infoclearFreeArgs - clear free type arguments (eg: clear handled deeplink info)navResult - provide the data passed to NavControllr:back(screen, navResult) as resultclearNavResult - clear passed nav result (eg: you want to show notification base on result and clear it not to re-show)rememberViewModel - create or provide view model scoped(linked) to current screenYou can create NavController using one of rememberNavController functions:
fun rememberNavController(
//...
)and display as part of any composable function
@Composable
fun Content() {
val navController = rememberNavController( /*... */)
Navigation(
navController = navController,
destinations = arrayOf(
// ...
),
modifier = Modifier.fillMaxSize()
)
}NavController will keep the screens data, view models, and states during navigation
[!IMPORTANT] The data may be cleared by system (eg: Android may clear memory)
fun rememberNavController( // ... saveable: Boolean? = null, // ... )
saveableproperty of remembered nav controller will indicate if we need to save/restore state or no
You can attach an extension to any destination
There is 2 extension types: with and without content
The content-extension allows to process content before destination body and after by specifying type (Overlay, Underlay)
Here is simple tracker extension:
// define extension
class AnalyticsExt(private val name: String) : ContentExtension<Any?>() {
@Composable
override fun NavDestinationScope<out Any?>.Content() {
val entry = navEntry()
LaunchedEffect(Unit) {
val service = /*...*/ // receive tracker
service.trackScreen(screenName = name, destination = entry.destination.name)
}
}
}
// apply ext to screen
val SomeScreen by navDestination<Args>(
AnalyticsExt("SomeScreen")
) {
// screen content
}
[!IMPORTANT] Only 'Savable' types of params & args will be available to use within
saveablenav controllerseg: Android - Parcelable + any bundlable primitives
[!IMPORTANT]
Type checking has run into a recursive problem. Easiest workaround: specify types of your declarations explicitlyide error.val SomeScreen1 by navDestination<Args> { val navController = navController() Button( onClick = { navController.navigate(SomeScreen2) }, // << error here content = { Text("goScreen2") } ) } val SomeScreen2 by navDestination<Args> { val navController = navController() Button( onClick = { navController.navigate(SomeScreen1) }, // << or here content = { Text("goScreen2") } ) }Appears when it is circular initialization happen (Screen1 knows about Screen2 who knows about Screen1 ...)
Solution: just define types of root(any in chain) screens explicitly
val SomeScreen1: NavDestination<Unit> by navDestination { /* ... */ }
[!IMPORTANT] Why is my system back button works wired with custom back handler?
While using custom back handler do not forget 2 rules
- Always place
NavBackHandlerbeforeNavigation- use
Navigation(handleSystemBackEvent = false)flag to disable extra back handler
See the examples here
Or try them in browser (require WASM support) here
// there is 2 common ideas behind handle complex navigation
//---- idea 1 -----
// create some data/param that will be passed via free args
// each screen handle this arg and opens `next` screen
val DeeplinkScreen by navDestination<Args> {
val deeplink = freeArgs<DeeplinkData>() // take free args
val deeplinkNavController = rememberNavController(
key = "deeplinkNavController",
startDestination = ShopScreen
) {
// handle deeplink and open next screen
if (deeplink != null) {
editNavStack { _->
listOf(
ShopScreen.toNavEntry(),
CategoryScreen.toNavEntry(navArgs = deeplink.categoryId),
DetailScreen.toNavEntry(navArgs = DetailParams(deeplink.productName, deeplink.productId))
)
}
clearFreeArgs()
}
}
Navigation(/*...*/)
}
//---- idea 2 -----
// use route-api
if (deeplink != null) {
navController?.route {
element(ShopScreen)
element(CategoryScreen.toNavEntry(navArgs = deeplink.categoryId))
element(DetailScreen.toNavEntry(navArgs = DetailParams(deeplink.productName, deeplink.productId)))
}
deepLinkController.clearDeepLink()
}startDestination = null + LaunchEffect \ DisposableEffect to make start destination dynamic and see 1 frame of animation // LaunchEffect & DisposableEffect are executed on `next` frame, so you may see 1 frame of animation
// to avoid this effect use `configuration` lambda within `rememberNavController` fun
val deeplinkNavController = rememberNavController(
key = "deeplinkNavController",
startDestination = ShopScreen,
) { // executed right after being created or restored
// so you can handle initial navigation here without any animations
}
// Yep, there is 2-pane layout example. You can also create fully custom layout by using `scene` api
val nc = rememberNavController(
key = "nav controller",
startDestination = SomeDest1,
)
// using scene api
NavigationScene(
navController = nc,
destinations = arrayOf(
SomeDest1,
SomeDest2,
SomeDest3,
)
) {
// place you destinations as you want ( !!!CAUTION!!! do not render same entry twice in a frame)
AnimatedContent(
targetState = nc.currentNavEntryAsState(),
contentKey = { it?.contentKey() },
transitionSpec = { navigationFadeInOut() }
) {
// you can also draw an entries from the whole nav stack if you need (but be careful)
EntryContent(it)
}
}
Library provides a utility function TiamatPreview for previewing individual navigation destinations in Compose Preview.
[!NOTE] Preview works best for pure Compose UI code. If your destination contains ViewModels, dependency injection, or complex app logic, consider creating separate preview functions for specific UI components instead of the entire destination.
Usage:
// Define your destination
val DemoScreen by navDestination<Unit> {
Text("Demo Screen")
}
// Create preview
@Preview
@Composable
private fun DemoScreenPreview() {
TiamatPreview(destination = DemoScreen)
}For screens with arguments:
data class UserProfileArgs(val userId: String, val userName: String)
val UserProfileScreen by navDestination<UserProfileArgs> {
val args = navArgs()
Column {
Text("User: ${args.userName}")
Text("ID: ${args.userId}")
}
}
@Preview
@Composable
private fun UserProfileScreenPreview() {
TiamatPreview(
destination = UserProfileScreen,
navArgs = UserProfileArgs(userId = "123", userName = "John")
)
}For complex destinations with ViewModels or app logic:
// Instead of previewing the entire destination
val ComplexScreen by navDestination<Unit> {
val viewModel = viewModel<MyViewModel>()
val data by viewModel.data.collectAsState()
ComplexScreenContent(data = data)
}
// Create preview for the UI component
@Composable
private fun ComplexScreenContent(data: MyData) {
Column {
Text("Title: ${data.title}")
// ... rest of UI
}
}
@Preview
@Composable
private fun ComplexScreenContentPreview() {
ComplexScreenContent(
data = MyData(title = "Preview Title")
)
}Nothing specific (yet)
Tiamat overrides LocalLifecycleOwner for each destination. This makes it compatible with lifecycle-aware components
See an example of CameraX usage: CameraXLifecycleScreen.kt
Nothing specific (yet)
Android: ./gradlew sample:app-android:assembleDebug
Jvm: ./gradlew sample:app-jvm:run
Jvm + hot-reload: ./gradlew sample:app-jvm:hotRun
Web: ./gradlew :sample:app-wasm:wasmJsBrowserDevelopmentRun
iOS: run XCode project or else use KMP plugin iOS target
other commands:
check ABI: ./gradlew checkAbi
update ABI: ./gradlew updateAbi
kover html report: ./gradlew :tiamat:koverHtmlReport
print test coverage: ./gradlew :tiamat:koverLog
run detekt checks: ./gradlew detekt
Thank you for your help! ❤️
Developed by ComposeGears 2025
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.