Building user interfaces (UIs) with Compose Multiplatform is becoming popular. When building your app, you might look for good ways to structure it. For UI architecture, you can use Circuit from Slack. For dependency injection, you can use Koin. They work well together.
This article explains how to use Koin and Circuit together in a Compose Multiplatform project. We will create a simple app that moves between two screens. This will help you understand how they connect.
What are Koin and Circuit?
- Koin: A simple dependency injection tool for Kotlin. It helps you manage the parts of your application in an easy way.
- Circuit: A library from Slack for UI architecture. It helps you build UIs that are based on state and events. This makes your UI code separate from the platform it runs on. It is built for Jetpack Compose and works well with Compose Multiplatform.
Step 1: Add Dependencies
First, we need to add the Koin and Circuit libraries to our project.
1.1. Define Versions in gradle/libs.versions.toml
In your gradle/libs.versions.toml
file, add the versions and libraries:
[versions] koin = "4.1.0" circuit = "0.29.1" # ... other versions [libraries] koin-compose = { group = "io.insert-koin", name = "koin-compose", version.ref = "koin" } circuit = { group = "com.slack.circuit", name = "circuit-foundation", version.ref = "circuit" } # ... other libraries
1.2. Add Dependencies in composeApp/build.gradle.kts
Next, add these libraries to the commonMain
source set in your composeApp/build.gradle.kts
file. This makes Koin and Circuit available for all platforms your app supports.
// ... kotlin { // ... sourceSets { // ... commonMain.dependencies { // ... implementation(libs.koin.compose) implementation(libs.circuit) } // ... } } // ...
Step 2: Create Screens with Circuit
Now that the libraries are added, we can create our screens. We will use Circuit's main parts: Screen
, Presenter
, and the UI. We will make two screens: FooScreen
and BarScreen
.
2.1. FooScreen: The First Screen
FooScreen
is the first screen the user will see. It has a button to go to BarScreen
.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/foo/FooScreen.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen data object FooScreen : Screen { data class State( val eventSink: (Event) -> Unit, ) : CircuitUiState sealed class Event : CircuitUiEvent { data object NavigateBarButtonClicked : Event() } }
The FooPresenter
contains the logic for navigation.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/foo/FooPresenter.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo import androidx.compose.runtime.Composable import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar.BarScreen class FooPresenter( private val navigator: Navigator, ) : Presenter<FooScreen.State> { @Composable override fun present(): FooScreen.State { return FooScreen.State { event -> when (event) { FooScreen.Event.NavigateBarButtonClicked -> navigator.goTo(BarScreen) } } } class Factory : Presenter.Factory { override fun create(screen: Screen, navigator: Navigator, context: CircuitContext): Presenter<*>? { return when (screen) { is FooScreen -> FooPresenter(navigator = navigator) else -> null } } } }
Here is the UI for FooScreen
:
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/foo/Foo.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun Foo(state: FooScreen.State, modifier: Modifier = Modifier) = Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(space = 16.dp, alignment = Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { Text("Foo screen") Button( content = { Text("Navigate to Bar screen") }, onClick = { state.eventSink(FooScreen.Event.NavigateBarButtonClicked) }, ) }
2.2. BarScreen: A Screen with Dependencies
BarScreen
shows how a presenter can get dependencies from Koin. It has a button that uses a HelloUseCase
and another button to go back.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/bar/BarScreen.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen data object BarScreen : Screen { data class State( val eventSink: (Event) -> Unit, ) : CircuitUiState sealed class Event : CircuitUiEvent { data object HelloButtonClicked : Event() data object NavigateBackButtonClicked : Event() } }
The BarPresenter
needs a HelloUseCase
to work.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/bar/BarPresenter.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar import androidx.compose.runtime.Composable import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import dev.yuyuyuyuyu.koincircuitintegrationexample.domain.useCase.HelloUseCase class BarPresenter( private val navigator: Navigator, private val helloUseCase: HelloUseCase, ) : Presenter<BarScreen.State> { @Composable override fun present(): BarScreen.State { return BarScreen.State { event -> when (event) { BarScreen.Event.HelloButtonClicked -> helloUseCase() BarScreen.Event.NavigateBackButtonClicked -> navigator.pop() } } } class Factory( private val helloUseCase: HelloUseCase, ) : Presenter.Factory { override fun create(screen: Screen, navigator: Navigator, context: CircuitContext): Presenter<*>? { return when (screen) { is BarScreen -> BarPresenter(helloUseCase = helloUseCase, navigator = navigator) else -> null } } } }
Here is the UI for BarScreen
:
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/bar/Bar.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun Bar(state: BarScreen.State, modifier: Modifier) = Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(space = 16.dp, alignment = Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { Text("Bar screen") Button( content = { Text("Hello, world!") }, onClick = { state.eventSink(BarScreen.Event.HelloButtonClicked) }, ) Button( content = { Text("Navigate back") }, onClick = { state.eventSink(BarScreen.Event.NavigateBackButtonClicked) }, ) }
Step 3: Create Koin Modules
Now, we will create Koin modules. These modules will provide the dependencies for our application. This includes the Circuit
object and other dependencies like HelloUseCase
.
3.1. Domain and Data Modules
First, we create modules for our domain and data layers. In this example, we only have a simple HelloUseCase
.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/di/domainModule.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.di import dev.yuyuyuyuyu.koincircuitintegrationexample.domain.useCase.HelloUseCase import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val domainModule = module { singleOf(::HelloUseCase) }
We also have a dataModule
. But this sample app does not have a data layer, so the module is empty.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/di/dataModule.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.di import org.koin.dsl.module val dataModule = module { // add modules if needed }
3.2. UI Module with Circuit
Next, we create a uiModule
that provides the Circuit
object. This is where we connect Koin and Circuit. We set up Circuit with our screens and presenters. We use Koin's get()
function to give the HelloUseCase
to the BarPresenter.Factory
.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/di/uiModule.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.di import com.slack.circuit.foundation.Circuit import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar.Bar import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar.BarPresenter import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.bar.BarScreen import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo.Foo import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo.FooPresenter import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo.FooScreen import org.koin.dsl.module val uiModule = module { single { Circuit.Builder() .addUi<FooScreen, FooScreen.State> { state, modifier -> Foo(state = state, modifier = modifier) } .addPresenterFactory(factory = FooPresenter.Factory()) .addUi<BarScreen, BarScreen.State> { state, modifier -> Bar(state = state, modifier = modifier) } .addPresenterFactory( factory = BarPresenter.Factory( helloUseCase = get() ) ) .build() } }
3.3. Application Module
Finally, we create a main application module that includes all the other modules.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/di/koinCircuitIntegrationExampleAppModule.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.di import org.koin.dsl.module val koinCircuitIntegrationExampleAppModule = module { includes(uiModule, domainModule, dataModule) }
Step 4: Connect Koin and Circuit in the Main App
Now that we have our screens and Koin modules, the last step is to connect them in our main composable. We will use the KoinApplication
composable to start Koin. We will use CircuitCompositionLocals
to give the Circuit
object to our UI.
composeApp/src/commonMain/kotlin/dev/yuyuyuyuyu/koincircuitintegrationexample/ui/KoinCircuitIntegrationExampleApp.kt
package dev.yuyuyuyuyu.koincircuitintegrationexample.ui import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator import dev.yuyuyuyuyu.koincircuitintegrationexample.di.koinCircuitIntegrationExampleAppModule import dev.yuyuyuyuyu.koincircuitintegrationexample.ui.foo.FooScreen import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.KoinApplication import org.koin.compose.koinInject @Composable @Preview fun KoinCircuitIntegrationExampleApp() { val backStack = rememberSaveableBackStack(root = FooScreen) val navigator = rememberCircuitNavigator(backStack) {} KoinApplication( application = { printLogger() modules(koinCircuitIntegrationExampleAppModule) }, ) { MaterialTheme { CircuitCompositionLocals(circuit = koinInject()) { NavigableCircuitContent(navigator, backStack) } } } }
This is how it works:
-
KoinApplication
starts Koin with ourkoinCircuitIntegrationExampleAppModule
. -
koinInject()
gets theCircuit
object that we defined in ouruiModule
. -
CircuitCompositionLocals
makes theCircuit
object available to all composables in the UI tree. -
NavigableCircuitContent
uses theCircuit
object to manage screen navigation and display.
A big benefit of the koin-compose
library is the KoinApplication
composable. If your Compose Multiplatform project does not use Android, you do not need to call startKoin { ... }
for each platform. You can just use KoinApplication
in your main composable to make Koin available.
Conclusion
When you use Koin and Circuit together, you get two big benefits. Circuit gives you a clean UI architecture. Koin gives you a simple way to manage dependencies. This combination helps you build Compose Multiplatform applications that are easy to scale, test, and maintain. The setup is simple and gives you a strong base for managing dependencies in your whole app, from the data layer to the UI presenters.
Happy coding!
Top comments (0)