A Compose-driven architecture for Kotlin Multiplatform. A Presenter is a @Composable that
returns state; a Ui is a @Composable that renders it. A Screen keys the two together.
Runs on Android, Desktop (JVM) and iOS.
dependencies {
implementation("io.github.avelon1a:orbit-foundation:0.0.11")
implementation("io.github.avelon1a:orbit-overlay:0.0.11")
testImplementation("io.github.avelon1a:orbit-test:0.0.11")
}orbit-foundation pulls in orbit-runtime transitively. Depend on orbit-runtime alone in
modules that hold presenters but no UI — it has no compose-ui dependency.
Screen is a plain commonMain interface. Back stack persistence runs through
kotlinx-serialization on every platform, so there is no @Parcelize, no expect/actual
annotation shim, and no Android-only supertype in the core types.
@Serializable
data object HomeScreen : Screen
@Serializable
data class DetailScreen(val id: String) : ScreenScreens must be registered with setScreenSerializers or saving the back stack fails at
runtime rather than at compile time. This is the one cost of dropping Parcelable.
data class CounterState(val count: Int, val eventSink: (CounterEvent) -> Unit) : OrbitUiState
sealed interface CounterEvent : OrbitUiEvent {
data object Increment : CounterEvent
data object Close : CounterEvent
}
class CounterPresenter(private val navigator: Navigator) : Presenter<CounterState> {
@Composable
override fun present(): CounterState {
var count by rememberRetained { mutableIntStateOf(0) }
return CounterState(count) { event ->
when (event) {
CounterEvent.Increment -> count++
CounterEvent.Close -> navigator.pop()
}
}
}
}rememberRetained survives configuration changes and navigating away and back, without a
ViewModel of your own.
val orbit = Orbit.Builder()
.addPresenterFactory { screen, navigator, _ ->
when (screen) {
is HomeScreen -> HomePresenter(navigator)
else -> null
}
}
.addUiFactory { screen, _ ->
when (screen) {
is HomeScreen -> ui<HomeState> { state, modifier -> HomeContent(state, modifier) }
else -> null
}
}
.setScreenSerializers {
subclass(HomeScreen::class)
subclass(DetailScreen::class)
}
.build()
setContent {
val backStack = rememberSaveableBackStack(HomeScreen, SampleJson)
ProvideOrbit(orbit) {
val navigator = rememberNavigator(backStack) { finish() }
BackHandler(enabled = !backStack.isAtRoot) { navigator.pop() }
ContentWithOverlays {
SharedElementTransitionLayout {
NavigableOrbitContent(navigator, backStack, Modifier.fillMaxSize())
}
}
}
}Factories are registered by hand. There is no annotation processor.
A dialog becomes a suspending call, so a presenter awaits a decision inline instead of
juggling showDialog flags.
DetailEvent.RequestDelete -> scope.launch {
val confirmed = overlayHost.show(ConfirmOverlay("Delete ${screen.id}?"))
if (confirmed) navigator.pop(DeletedResult(screen.id))
}@Test
fun incrementing() = runTest {
CounterPresenter(FakeNavigator(CounterScreen)).test {
awaitItem().eventSink(CounterEvent.Increment)
assertEquals(1, awaitItem().count)
cancelAndIgnoreRemainingEvents()
}
}No Compose UI test rule, no Robolectric, no emulator.
Full guides live at https://avelon1a.github.io/orbit/.
Apache 2.0. See LICENSE.