Master Kotlin Multiplication navigation with Decompose — Part 1

Yeldar Nurpeissov
6 min readJun 21, 2024

--

Start learning KMP in 2024! This article aims to help you integrate Kotlin Multiplatform (KMP) with Decompose.

Follow these steps to get started:

  1. Why Decompose?
  2. Creating the KMP project.
  3. Adding dependencies.
  4. Creating Components.
  5. 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.

  1. Visit the Kotlin Multiplatform Wizard.
  2. Select Android, iOS, Desktop and Web targets.
  3. Download the project.
  4. 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.

  1. 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
}
  1. Value is an observable value holder, provided by the Decompose library, similar to Coroutine Flow, LiveData, and Observable and etc.
  2. 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()
}
  1. 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)
}
}
}
  1. 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
}
  1. Generate list of posts.
  2. 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)
}
}
}
}
}
  1. 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
}
}
  1. The property stack holds an observable stack of child components.
  2. 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
}
}
  1. StackNavigation represents the stack as a List, with the last element at the top and the first at the bottom.
  2. We create an observable stack using the childStack() extension function of ComponentContext.
  3. Factory method creates components by mapping the config.
  4. Pushing a new config to the stack navigates to a new screen.
  5. Popping the stack navigates back to the previous screen.
  6. 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(),
)
}
}
}
  1. Children composable function takes a stack and gives a child into content lambda.
  2. Add slide animation for content replacement.
  3. Show appropriate content based on the current child instance.

App

@Composable
@Preview
fun App(rootComponent: RootComponent) {
MaterialTheme {
RootContent(
component = rootComponent,
modifier = Modifier.fillMaxSize(),
)
}
}
  1. 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:

Connect

Let’s connect on LinkedIn and subscribe for updates!

Thank you!

--

--