Screens, presenters and UIs¶
The three core types form a loop: a Screen selects a Presenter and a Ui; the presenter
produces state; the UI renders it and sends events back through the state's eventSink.
Screen ──► Presenter.present() ──► State ──► Ui.Content(state)
▲ │
└──────────── event ◄────────────────┘
Screen¶
A marker interface. Screens carry only the arguments needed to identify what to show.
Use data object for screens with no arguments and data class for those with. Equality
matters: resetRoot's state saving is keyed on the screen value.
StaticScreen¶
A screen that has UI but no presenter — a static page, a placeholder.
Orbit skips presenter lookup for these and supplies StaticUiState, so you only register a UI:
.addUiFactory { screen, _ ->
when (screen) {
is AboutScreen -> ui<StaticUiState> { _, modifier -> AboutContent(modifier) }
else -> null
}
}
State and events¶
data class DetailState(
val id: String,
val loading: Boolean,
val eventSink: (DetailEvent) -> Unit,
) : OrbitUiState
sealed interface DetailEvent : OrbitUiEvent {
data object Back : DetailEvent
data object Refresh : DetailEvent
}
OrbitUiState is @Stable and OrbitUiEvent is @Immutable, which tells the Compose compiler
it can skip recomposition when values have not changed. Keep state classes made of stable types.
The eventSink convention is what keeps the UI free of any presenter reference. The UI just
calls state.eventSink(SomeEvent).
Presenter¶
class DetailPresenter(
private val screen: DetailScreen,
private val navigator: Navigator,
private val repository: DetailRepository,
) : Presenter<DetailState> {
@Composable
override fun present(): DetailState {
var refreshCount by rememberRetained { mutableIntStateOf(0) }
val detail by produceState<Detail?>(null, screen.id, refreshCount) {
value = repository.load(screen.id)
}
return DetailState(id = screen.id, loading = detail == null) { event ->
when (event) {
DetailEvent.Back -> navigator.pop()
DetailEvent.Refresh -> refreshCount++
}
}
}
}
Because present() is a Composable, effects work exactly as they do in UI code: LaunchedEffect
for suspending work, produceState for loading, collectAsState for flows. There is no
viewModelScope — the composition's lifetime is the scope.
Dependencies come through the constructor. Orbit does not include a DI container; use whatever you already have and construct presenters inside the factory.
presenterOf¶
For a presenter with no dependencies, skip the class:
Ui¶
@Composable
fun DetailContent(state: DetailState, modifier: Modifier = Modifier) {
Column(modifier) {
if (state.loading) {
CircularProgressIndicator()
} else {
Text(state.id)
}
Button(onClick = { state.eventSink(DetailEvent.Refresh) }) { Text("Refresh") }
}
}
Wrap it with the ui { } builder when registering, or implement Ui<DetailState> directly if
you prefer a class.
Always accept and apply the Modifier parameter — Orbit passes layout constraints through it.
The registry¶
val orbit = Orbit.Builder()
.addPresenterFactory(HomePresenterFactory())
.addUiFactory(HomeUiFactory())
.setOnUnavailableContent { screen, modifier ->
Text("No route for $screen", modifier)
}
.setDefaultNavDecoration(AnimatedNavDecoration)
.setScreenSerializers { subclass(HomeScreen::class) }
.build()
For a single screen there are reified shortcuts:
Orbit.Builder()
.addPresenter<AboutScreen, _>(aboutPresenter)
.addUi<AboutScreen, _>(aboutUi)
.build()
newBuilder() copies an existing instance, which is useful for swapping one screen out in tests
or in a debug build.
Factory ordering¶
Factories are consulted in registration order and the first non-null wins. Registering a
catch-all factory first will shadow everything after it. Resolution is cached per screen inside
OrbitContent, so a long factory list is not a per-frame cost.
Rendering a single screen¶
NavigableOrbitContent is the usual host, but a single screen can be rendered directly —
useful for a detail pane in a two pane layout:
Without a navigator argument it defaults to Navigator.NoOp, which accepts calls and does
nothing.
OrbitContext¶
Each OrbitContent creates a child OrbitContext, a small tag bag threaded to factories for
parent/child information:
It is an extension point, not a DI container — keep it for things that genuinely depend on where a screen is being rendered.