Master Kotlin Multiplication navigation with Decompose — Part 1
Start learning KMP in 2024! This article aims to help you integrate Kotlin Multiplatform (KMP) with Decompose.
Follow these steps to get started:
- Why Decompose?
- Creating the KMP project.
- Adding dependencies.
- Creating Components.
- Runing the App.
Why Decompose?
Decompose simplifies state management and navigation in Kotlin Multiplatform projects, making your codebase more maintainable and scalable.
Creating the KMP project.
- Visit the Kotlin Multiplatform Wizard.
- Select Android, iOS, Desktop and Web targets.
- Download the project.
- Extract and open the project with IDE. I am using the Android Studio Koala | 2024.1.1.
Adding dependencies.
We need two dependencies and one plugin.
- decompose: Decompose core library.
- decompose-extensions-compose: Contains extension functions for subscription to state.
- kotlinSerialization: Necessary when creating a child component.
- Add these dependencies in your libs.versions.toml file:
[versions]
...
decompose = "3.1.0"
[libraries]
...
decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-extensions-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" }
[plugins]
...
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
2. Update build.gradle.kts inside the composeApp folder.
plugins {
...
// Kotlin Serialization Plugin
alias(libs.plugins.kotlinSerialization)
}
kotlin {
...
sourceSets {
...
commonMain.dependencies {
...
// material icons, we need arrow back icon for navigation button
implementation(compose.materialIconsExtended)
// decompose
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
}
}
}
3. Finally, sync Gradle changes.
Creating Components.
For demonstration purposes, we’ll create three components: Root, List, and Detail. The ListComponent contains a list of posts, and when a post is clicked, we show detailed information in the DetailComponent.
Post Data Transfer Object
@Serializable
data class Post(
val id: String,
val author: String,
val title: String,
val description: String,
)
DetailComponent
interface DetailComponent {
val model: Value<Post> // 1
fun onBackPressed() // 2
}
- Value is an observable value holder, provided by the Decompose library, similar to Coroutine Flow, LiveData, and Observable and etc.
- We call onBackPressed() when the back button is clicked.
DefaultDetailComponent
class DefaultDetailComponent(
componentContext: ComponentContext,
post: Post,
private val onFinished: () -> Unit,
) : DetailComponent, ComponentContext by componentContext {
override val model: Value<Post> = MutableValue(post)
override fun onBackPressed() = onFinished()
}
- The onFinished() lambda notifies the parent component when the current component is destroyed.
DetailContent
@Composable
fun DetailContent(
component: DetailComponent,
modifier: Modifier = Modifier,
) {
val state by component.model.subscribeAsState() // 1
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Detail") },
navigationIcon = {
IconButton(onClick = component::onBackPressed) {
Icon(Icons.Outlined.ArrowBackIosNew, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(state.title)
Text(state.description)
Text(state.author)
}
}
}
- We subscribe to the new state using the subscribeAsState() extenstion function of the Value class.
ListComponent
It is similar to DetailComponent but includes a list of posts and a method to handle post clicks.
interface ListComponent {
val model: Value<List<Post>>
fun onPostClicked(post: Post)
}
DefaultListComponent
class DefaultListComponent(
componentContext: ComponentContext,
private val postClicked: (Post) -> Unit,
) : ListComponent, ComponentContext by componentContext {
override val model: Value<List<Post>> = MutableValue(
(0..16).map { // 1
Post(
id = it.toString(),
title = "Title-#$it",
description = "Description-#$it",
author = "Author-#$it",
)
}
)
override fun onPostClicked(post: Post) = postClicked(post) // 2
}
- Generate list of posts.
- Pass post click handling to the parent component.
ListContent
@Composable
fun ListContent(
component: ListComponent,
modifier: Modifier = Modifier,
) {
val state by component.model.subscribeAsState()
Scaffold(
modifier = modifier,
topBar = { TopAppBar(title = { Text("List") }) }
) { paddingValues ->
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.padding(paddingValues)
) {
items(state) { post ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { component.onPostClicked(post) }
.padding(16.dp)
) {
Text(post.title)
}
}
}
}
}
- Here we are just showing list of posts.
RootComponent
interface RootComponent {
val stack: Value<ChildStack<*, Child>> // 1
sealed interface Child { // 2
class List(val component: ListComponent) : Child
class Detail(val component: DetailComponent) : Child
}
}
- The property stack holds an observable stack of child components.
- Declare child components.
DefaultRootComponent
class DefaultRootComponent(
componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {
private val nav = StackNavigation<Config>() // 1
override val stack: Value<ChildStack<*, RootComponent.Child>> = childStack( // 2
source = nav,
serializer = Config.serializer(),
initialConfiguration = Config.List,
handleBackButton = true,
childFactory = ::child,
)
private fun child(
config: Config,
componentContext: ComponentContext
): RootComponent.Child = when (config) { // 3
Config.List -> RootComponent.Child.List(
DefaultListComponent(
componentContext = componentContext,
postClicked = { post ->
nav.pushNew(Config.Detail(post)) // 4
}
)
)
is Config.Detail -> RootComponent.Child.Detail(
DefaultDetailComponent(
componentContext = componentContext,
post = config.post,
onFinished = {
nav.pop() // 5
},
)
)
}
@Serializable
private sealed interface Config { // 6
@Serializable
data object List : Config
@Serializable
data class Detail(val post: Post) : Config
}
}
- StackNavigation represents the stack as a List, with the last element at the top and the first at the bottom.
- We create an observable stack using the childStack() extension function of ComponentContext.
- Factory method creates components by mapping the config.
- Pushing a new config to the stack navigates to a new screen.
- Popping the stack navigates back to the previous screen.
- With the config, we can navigate to specific components and pass arguments.
RootContent
@Composable
fun RootContent(
component: RootComponent,
modifier: Modifier = Modifier,
) {
Children( // 1
stack = component.stack,
modifier = modifier,
animation = stackAnimation(slide()), // 2
) {
when (val child = it.instance) { // 3
is RootComponent.Child.Detail -> DetailContent(
component = child.component,
modifier = Modifier.fillMaxSize(),
)
is RootComponent.Child.List -> ListContent(
component = child.component,
modifier = Modifier.fillMaxSize(),
)
}
}
}
- Children composable function takes a stack and gives a child into content lambda.
- Add slide animation for content replacement.
- Show appropriate content based on the current child instance.
App
@Composable
@Preview
fun App(rootComponent: RootComponent) {
MaterialTheme {
RootContent(
component = rootComponent,
modifier = Modifier.fillMaxSize(),
)
}
}
- Show RootContent
Running the App.
Now, let's pass the RootComponent to our App composable function.
1. MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Always create the root component outside Compose on the main thread
val rootComponent = DefaultRootComponent(defaultComponentContext())
setContent {
App(rootComponent = rootComponent)
}
}
}
2. Desktop main.kt
fun main() {
val lifecycle = LifecycleRegistry()
val rootComponent = runOnUiThread {
DefaultRootComponent(DefaultComponentContext(lifecycle))
}
application {
val windowState = rememberWindowState()
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Decompose Quick Guide",
) {
LifecycleController(
lifecycleRegistry = lifecycle,
windowState = windowState,
windowInfo = LocalWindowInfo.current,
)
App(rootComponent = rootComponent)
}
}
}
internal fun <T> runOnUiThread(block: () -> T): T {
if (SwingUtilities.isEventDispatchThread()) {
return block()
}
var error: Throwable? = null
var result: T? = null
SwingUtilities.invokeAndWait {
try {
result = block()
} catch (e: Throwable) {
error = e
}
}
error?.also { throw it }
@Suppress("UNCHECKED_CAST")
return result as T
}
3. iOS Compose
fun MainViewController() = ComposeUIViewController {
val rootComponent = remember {
DefaultRootComponent(DefaultComponentContext(ApplicationLifecycle()))
}
App(rootComponent = rootComponent)
}
4. Web
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()
val rootComponent = DefaultRootComponent(DefaultComponentContext(lifecycle))
lifecycle.attachToDocument()
ComposeViewport(document.body!!) {
App(rootComponent = rootComponent)
}
}
private fun LifecycleRegistry.attachToDocument() {
fun onVisibilityChanged() {
if (visibilityState(document) == "visible") {
resume()
} else {
stop()
}
}
onVisibilityChanged()
document.addEventListener(type = "visibilitychange", callback = { onVisibilityChanged() })
}
// Workaround for Document#visibilityState not available in Wasm
@JsFun("(document) => document.visibilityState")
private external fun visibilityState(document: Document): String
Conclusion
Hey, congratulations! 🎉 You’ve built your KMP project with Decompose! The source code is on GitHub.
What’s next?
There’s room for improvement. Stay tuned for upcoming articles on Decompose and other enhancements.
Other parts:
- Master Kotlin Multiplatform with Decompose — Part 2: Dependency Injection, Kodein, Koin
- Master Kotlin Multiplatform with Decompose — Part 3: Restoring state with InstanceKeeper and StateKeeper
- Master Kotlin Multiplatform with Decompose — Part 4: MVI
Connect
Let’s connect on LinkedIn and subscribe for updates!
Thank you!