Skip to content

Quick start

This builds a two screen app: a list that navigates to a detail screen. Every piece is shown in full, in the order you would write it.

1. Add the dependencies

dependencies {
    implementation("io.github.avelon1a:orbit-foundation:0.0.11")
}

Orbit needs the kotlinx-serialization plugin in any module that declares screens:

plugins {
    alias(libs.plugins.kotlin.serialization)
}

2. Declare the screens

Screens are the routing keys. They are @Serializable so the back stack can be written to disk and rebuilt after process death.

@Serializable
data object HomeScreen : Screen

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

Keep screens small — an id, not a whole model. The presenter loads the rest.

3. Declare state and events

State is what the UI draws. Events are what the UI sends back. The link between them is an eventSink property on the state, which is why a UI never needs a reference to its presenter.

data class HomeState(
    val items: ImmutableList<String>,
    val eventSink: (HomeEvent) -> Unit,
) : OrbitUiState

sealed interface HomeEvent : OrbitUiEvent {
    data class OpenDetail(val id: String) : HomeEvent
}

Use an immutable list

ImmutableList from kotlinx-collections-immutable lets Compose treat the state as stable and skip recomposition when it has not changed. A plain List is not considered stable.

4. Write the presenter

A presenter is a @Composable returning state. Anything legal in a Composable is legal here: remember, LaunchedEffect, collectAsState.

class HomePresenter(private val navigator: Navigator) : Presenter<HomeState> {
    @Composable
    override fun present(): HomeState {
        val items = remember { List(30) { "Item ${it + 1}" }.toImmutableList() }

        return HomeState(items) { event ->
            when (event) {
                is HomeEvent.OpenDetail -> navigator.goTo(DetailScreen(event.id))
            }
        }
    }
}

5. Write the UI

The UI takes state and a Modifier, and nothing else.

@Composable
fun HomeContent(state: HomeState, modifier: Modifier = Modifier) {
    LazyColumn(modifier.fillMaxSize()) {
        items(state.items) { item ->
            Text(
                text = item,
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { state.eventSink(HomeEvent.OpenDetail(item)) }
                    .padding(16.dp),
            )
        }
    }
}

6. Register everything

Orbit is the registry that maps screens to presenters and UIs. Factories run in registration order and the first non-null result wins; returning null means "not mine, try the next one".

fun buildOrbit(): Orbit = Orbit.Builder()
    .addPresenterFactory { screen, navigator, _ ->
        when (screen) {
            is HomeScreen -> HomePresenter(navigator)
            is DetailScreen -> DetailPresenter(screen, navigator)
            else -> null
        }
    }
    .addUiFactory { screen, _ ->
        when (screen) {
            is HomeScreen -> ui<HomeState> { state, modifier -> HomeContent(state, modifier) }
            is DetailScreen -> ui<DetailState> { state, modifier -> DetailContent(state, modifier) }
            else -> null
        }
    }
    .setScreenSerializers {
        subclass(HomeScreen::class)
        subclass(DetailScreen::class)
    }
    .build()

Register every screen

A screen missing from setScreenSerializers compiles fine and fails at runtime the moment the back stack is saved. This is the trade Orbit makes by not using Parcelable. Add the subclass line at the same time you add the screen.

7. Host it

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val backStack = rememberSaveableBackStack(HomeScreen, AppJson)
            val orbit = remember(backStack) { buildOrbit() }

            ProvideOrbit(orbit) {
                val navigator = rememberNavigator(backStack) { finish() }
                BackHandler(enabled = !backStack.isAtRoot) { navigator.pop() }

                NavigableOrbitContent(
                    navigator = navigator,
                    backStack = backStack,
                    modifier = Modifier.fillMaxSize(),
                )
            }
        }
    }
}

AppJson is the Json instance carrying your screen serializers:

val AppJson = Json {
    serializersModule = SerializersModule {
        polymorphic(Screen::class) {
            subclass(HomeScreen::class)
            subclass(DetailScreen::class)
        }
    }
}

The back stack is created before the Orbit instance because rememberSaveableBackStack needs serializers, not the registry. If you would rather create it inside ProvideOrbit, use the overload that takes the Orbit instead of a Json.

rememberNavigator's second argument runs when a pop happens at the root — on Android, finish the activity.

What you get for free

  • Back and forward navigation with a slide transition.
  • Back stack survival across rotation and process death.
  • Per screen state scoping, so pushing the same screen twice gives two independent instances.

Next

  • Retained state — surviving rotation without a ViewModel.
  • Overlays — dialogs that return a value.
  • Testing — presenter tests with no emulator.