Building a simple Web Application with Compose Multiplatform using Decompose
Hello everyone 👋🏻, today I want to share with you a very practical article, leaving theory aside to show you how to easily add Decompose to your Web project, step by step. 🚀
What is Decompose?
Decompose is a Kotlin Multiplatform library for breaking down your code into tree-structured lifecycle-aware business logic components (aka BLoC), with routing functionality and pluggable UI (Jetpack/Multiplatform Compose, Android Views, SwiftUI, Kotlin/React, etc.).
You can consult its official documentation for further details.
Let’s get to the point
First of all we are going to create a new project using Kotlin Multiplatform Wizard. For this example we just need to have web option selected, remember that due the date Compose Multiplatform for web is in experimental.
If you inspect the project content, including hidden files, you will notice references to Fleet, but you will still be able to open your project with Android Studio. Make sure you have configured the latter to work properly with Kotlin Multiplatform.
You can try the demo included in the default template by running the
You can see the demo that comes with the default template by executing the following command:
./gradlew :composeApp:wasmJsRun
Decompose adds support for Kotlin/Wasm starting from its version 3.0.0. As of now, the latest published version is 3.0.0-alpha07. Open your version catalog and add the following dependencies:
[versions]
# Other versions
decompose = "3.0.0-alpha07"
[libraries]
# Other libraries
decompose = { group = "com.arkivanov.decompose", name = "decompose", version.ref = "decompose" }
decompose-extensions-compose = { group = "com.arkivanov.decompose", name = "extensions-compose", version.ref = "decompose" }
Add the dependencies for Decompose
sourceSets {
commonMain.dependencies {
// Other dependencies
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
}
}
Now we will create two packages called firstscreen
and secondscreen
where we will add the necessary files for each of our views for this example
Within the package firstscreen
, we will add our first component to handle the events of our initial view.
FirstScreenComponent.kt
package firstscreen
import com.arkivanov.decompose.ComponentContext
interface FirstScreenComponent {
fun onNavigateToSecondScreen()
}
class DefaultFirstScreenComponent(
componentContext: ComponentContext,
private val onShowSecondScreenClicked: () -> Unit
): FirstScreenComponent, ComponentContext by componentContext {
override fun onNavigateToSecondScreen() {
onShowSecondScreenClicked()
}
}
Now we will create our first screen with the following code:
FirstScreen.kt
package firstscreen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@Composable
fun FirstScreen(
component: FirstScreenComponent
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
) {
Column {
Text(
text = "First Screen",
fontWeight = FontWeight.Bold
)
Button(onClick = { component.onNavigateToSecondScreen() }) {
Text("Navigate to Second Screen")
}
}
}
}
Similarly, we will do with the second screen.
SecondScreenComponent.kt
package secondscreen
import com.arkivanov.decompose.ComponentContext
interface SecondScreenComponent {
fun onBackToFirstScreen()
}
class DefaultSecondScreenComponent(
componentContext: ComponentContext,
private val onBackToFirstScreenClicked: () -> Unit
): SecondScreenComponent, ComponentContext by componentContext {
override fun onBackToFirstScreen() {
onBackToFirstScreenClicked()
}
}
SecondScreen.kt
package secondscreen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@Composable
fun SecondScreen(
component: SecondScreenComponent
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
) {
Column {
Text(
text = "Second Screen",
fontWeight = FontWeight.Bold
)
Button(onClick = { component.onBackToFirstScreen() }) {
Text("Navigate to First Screen")
}
}
}
}
Now we will create the entry point of our application, for which we will create a package called root
and add the following 3 files:
RootComponent.kt
package root
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.value.Value
import firstscreen.FirstScreenComponent
import secondscreen.SecondScreenComponent
internal interface RootComponent {
val stack: Value<ChildStack<*, Child>>
fun onBackClicked(toIndex: Int)
sealed class Child {
class Screen1(val component: FirstScreenComponent) : Child()
class Screen2(val component: SecondScreenComponent) : Child()
}
}
DefaultRootComponent.kt
package root
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.popTo
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.value.Value
import firstscreen.DefaultFirstScreenComponent
import firstscreen.FirstScreenComponent
import secondscreen.DefaultSecondScreenComponent
import secondscreen.SecondScreenComponent
internal class DefaultRootComponent(
componentContext: ComponentContext
): RootComponent, ComponentContext by componentContext {
private val navigation = StackNavigation<Config>()
override val stack: Value<ChildStack<*, RootComponent.Child>> =
childStack(
source = navigation,
serializer = null,
initialConfiguration = Config.Screen1,
handleBackButton = true,
childFactory = ::child,
)
override fun onBackClicked(toIndex: Int) {
navigation.popTo(index = toIndex)
}
private fun child(config: Config, childComponentContext: ComponentContext): RootComponent.Child =
when (config) {
is Config.Screen1 -> RootComponent.Child.Screen1(screen1Component(childComponentContext))
is Config.Screen2 -> RootComponent.Child.Screen2(screen2Component(childComponentContext))
}
private fun screen1Component(componentContext: ComponentContext): FirstScreenComponent =
DefaultFirstScreenComponent(
componentContext = componentContext,
onShowSecondScreenClicked = { navigation.push(Config.Screen2) },
)
private fun screen2Component(componentContext: ComponentContext): SecondScreenComponent =
DefaultSecondScreenComponent(
componentContext = componentContext,
onBackToFirstScreenClicked = navigation::pop,
)
private sealed interface Config {
data object Screen1 : Config
data object Screen2 : Config
}
}
Next, we will move the file App.kt
into the root
package and rename it to be called RootContent.kt
(the name is only by convention), and modify its content with the following code:
RootContent.kt
package root
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.scale
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import firstscreen.FirstScreen
import secondscreen.SecondScreen
@Composable
internal fun RootContent(
component: RootComponent
) {
MaterialTheme {
Children(
stack = component.stack,
modifier = Modifier.fillMaxSize(),
animation = stackAnimation(fade() + scale())
) {
when (val instance = it.instance) {
is RootComponent.Child.Screen1 -> FirstScreen(component = instance.component)
is RootComponent.Child.Screen2 -> SecondScreen(component = instance.component)
}
}
}
}
Finally, let’s modify the main.kt
so that it looks like the following:
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import com.arkivanov.decompose.DefaultComponentContext
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
import com.arkivanov.essenty.lifecycle.resume
import root.DefaultRootComponent
import root.RootContent
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()
val root = DefaultRootComponent(
componentContext = DefaultComponentContext(lifecycle = lifecycle)
)
lifecycle.resume()
CanvasBasedWindow(canvasElementId = "ComposeTarget") { RootContent(root) }
}
Launch the application once more and observe the changes.
./gradlew :composeApp:wasmJsRun
Sources
If you like my content and want to support my work, you can give me a cup of coffee ☕️ 🥰
Follow me in
- Twitter: @devjcastro
- Linkedin: devjcastro