Skip to content

Repository files navigation

Orbit

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.

No Parcelable

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) : Screen

Screens 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.

A screen in full

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.

Wiring

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.

Overlays return values

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))
}

Testing presenters

@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.

Documentation

Full guides live at https://avelon1a.github.io/orbit/.

License

Apache 2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages