Kotlin Multiplatform lifecycle-aware business logic components (aka BLoCs) with routing functionality and pluggable UI (Jetpack Compose, SwiftUI, JS React, etc.) This project is inspired by Badoos RIBs fork of the Uber RIBs framework.
Supported targets:
- Android
- iosX64, iosArm64
- JavaScript
Add Bintray repository to your root build.gradle file:
repositories {
maven {
url "https://dl.bintray.com/arkivanov/maven"
}
}Add Decompose dependency to your build.gradle:
implementation "com.arkivanov.decompose:decompose:<version>"Every component represents a piece of logic with lifecycle and optional pluggable UI.
Here is an example of simple Counter component:
class Counter {
private val _value = MutableValue(State())
val state: Value<State> = _value
fun increment() {
_value.reduce { it.copy(count = it.count + 1) }
}
data class State(val count: Int = 0)
}Jetpack Compose UI example:
@Composable
operator fun Counter.invoke() {
state { state ->
Column(horizontalGravity = Alignment.CenterHorizontally) {
Text(text = state.count.toString())
Button(onClick = ::increment) {
Text("Increment")
}
}
}
}SwiftUI example:
struct CounterView: View {
private let counter: Counter
@ObservedObject
private var state: ObservableValue<CounterState>
init(_ counter: Counter) {
self.counter = counter
self.state = ObservableValue(counter.state)
}
var body: some View {
VStack(spacing: 8) {
Text(self.state.value.text)
Button(action: self.counter.increment, label: { Text("Increment") })
}
}
}Each component has an associated ComponentContext which implements the following interfaces:
- RouterFactory, so you can create nested
Routersin yourComponenets - StateKeeperOwner, so you can preserve any state during configuration changes and/or process death
- InstanceKeeperOwner, so you can retain instances in your components (like with AndroidX ViewModels)
- LifecycleOwner, so each component has its own lifecycle
- BackPressedDispatcherOwner, so each component can handle back button events
So if a component requires any of the above features, just pass the ComponentContext via the component's constructor. When instantiating a root component we have to create ComponentContext manually. There are various helper functions and default implementations to simplify this process. Child contexts are provided by the Router for every child component.
A key unit is the Router. It is responsible for managing components, just like FragmentManager.
The Router supports back stack and so each component has its own Lifecycle. Each time a new component is pushed, the currently active component is stopped. When a component is popped from the back stack, the previous component is resumed. This allows business logic to run while the component is in the back stack.
Each component is created based on an associated Configuration. Configurations can be persisted via Android's saved state, thus allowing back stack restoration after configurations change or process death. When the back stack is restored, only currently active components are recreated. All others in the back stack remain destroyed, and recreated on demand when navigating back. Decompose defines both Parcelable interface and @Parcelize annotation in common code using expect/actual, which works well with Kotlin Multiplatform. You can read more about it here.
The Router has a state consisting of a currently active component and the back stack, so it can be rendered as normal.
Routers can be nested, and each component can have more than one Router.
Here is a very basic example of navigation between two children components:
class Child1(componentContext: ComponentContext) : ComponentContext by componentContext {
// omitted code
}
class Child2(componentContext: ComponentContext, data: String) : ComponentContext by componentContext {
// omitted code
}
class Parent(componentContext: ComponentContext) : ComponentContext by componentContext {
private val router =
router<Config, Any>(
initialConfiguration = Config.Child1,
componentFactory = ::createChild
)
val children: Value<RouterState<Config, Any>> get() = router.state
private fun createChild(config: Config, componentContext: ComponentContext): Any =
when (config) {
is Config.Child1 -> Child1(componentContext)
is Config.Child2 -> Child2(componentContext, data = config.data)
}
fun showChild2(data: String) {
router.push(Config.Child2(data = data))
}
fun popChild() {
router.pop()
}
sealed class Config : Parcelable {
@Parcelize
object Child1 : Config()
@Parcelize
class Child2(val data: String) : Config()
}
}Decompose provides the StateKeeper API for state preservation. Currently it relies on Parcelable interface. It can be used in multiplatform code but is only useful in Android.
Here is a quick example:
class Child1(componentContext: ComponentContext) : ComponentContext by componentContext {
private var state = stateKeeper.consume<State>("SAVED_STATE") ?: State()
init {
stateKeeper.register("SAVED_STATE") { state }
}
@Parcelize
private class State(val someValue: Int = 0) : Parcelable
}Decompose provides the InstanceKeeper API, similar to AndroidX ViewModels:
class Child1(componentContext: ComponentContext) : ComponentContext by componentContext {
private val viewModel = instanceKeeper.getOrCreate(::ViewModel)
private class ViewModel : InstanceKeeper.Instance {
override fun onDestroy() {
// Clean-up
}
}
}There are two sample apps: Counter and Todo List.
This sample demonstrates the following features:
- Nested components
- Routing
- Reused components
- State preservation (using
StateKeeper) - Retaining instances (using
InstanceKeeper) - Pluggable UI (Jetpack Compose, SwiftUI, JS React)
Content:
- Shared module which includes the following components:
- Counter - this component just increments the counter every 250 ms. It starts counting once created and stops when destroyed. So
Countercontinues counting while in the back stack, unless recreated. It uses theInstanceKeeper, so counting continues after configuration changes. - CounterInnerContainer - this component contains the
Counterand twoRouterson the left and on the right side. EachRouterdisplays its stack ofCountersand two buttons for navigation. "Next" button pushes anotherCounterto the correspondingRouter, "Prev" button pops the activeCounterfor theRouter. - CounterRootComponent - this component contains the
Counter, theRouterofCounterInnerContainerand a button pushing anotherCounterInnerContainerto the stack. System back button is used for backward navigation.
- Counter - this component just increments the counter every 250 ms. It starts counting once created and stops when destroyed. So
- Android sample app
- iOS sample app
- JavaScript sample app
This sample can be found here.
It demonstrates the following features:
- Nested components
- Routing
- Using
Lifecycle - Multi-module structure (one component per module)
- Inter-Component communication (via Reaktive, just an example)
- MVI using MVIKotlin
- Data persistance using SQLDelight
- Pluggable UI (Jetpack Compose, SwiftUI, JS React)
There are multiple components, each in a separate module:
- list - the top part of the
Mainscreen, displays a list of todo items. Tap on an item opens theEditorscreen. - add - the bottom part of the
Mainscreen, displays input field and a button. Tap on the button adds a new todo item to the list. - main - aggregates both
listandaddcomponents, represents theMainscreen. - edit - provides the ability to edit a selected todo item.
- root - aggregates both
mainandeditcomponents and usesRouterto switch between them.
Decompose — experiments with Kotlin Multiplatform lifecycle-aware components and navigation
Twitter: @arkann1985




