Skip to content

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.

@Serializable
data class DetailScreen(val id: String) : Screen

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.

@Serializable
data object AboutScreen : StaticScreen

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:

val aboutPresenter = presenterOf { AboutState(version = "0.0.11") }

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:

OrbitContent(
    screen = DetailScreen("42"),
    modifier = Modifier.weight(1f),
    navigator = navigator,
)

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:

.addPresenterFactory { screen, navigator, context ->
    val pane = context.tag<PaneInfo>()
    ...
}

It is an extension point, not a DI container — keep it for things that genuinely depend on where a screen is being rendered.