Skip to content

Overlays

An overlay is a dialog, sheet or any transient UI that produces a value. In Orbit, showing one is a suspending call that returns the user's answer.

val confirmed = overlayHost.show(ConfirmOverlay("Delete this item?"))
if (confirmed) navigator.pop(DeletedResult(screen.id))

No showDialog boolean, no onConfirm and onDismiss callbacks, no state to reset afterwards. The decision reads as a straight line.

Install

implementation("io.github.avelon1a:orbit-overlay:0.0.11")

Wrap your content once, above the navigation host:

ContentWithOverlays {
    NavigableOrbitContent(navigator, backStack, Modifier.fillMaxSize())
}

That provides LocalOverlayHost and renders whatever is currently showing on top of the content.

Writing an overlay

Implement Overlay<Result>. The Content function receives a navigator whose finish resumes the caller.

class ConfirmOverlay(private val message: String) : Overlay<Boolean> {
    @Composable
    override fun Content(navigator: OverlayNavigator<Boolean>) {
        AlertDialog(
            onDismissRequest = { navigator.finish(false) },
            title = { Text("Confirm") },
            text = { Text(message) },
            confirmButton = {
                TextButton(onClick = { navigator.finish(true) }) { Text("Delete") }
            },
            dismissButton = {
                TextButton(onClick = { navigator.finish(false) }) { Text("Cancel") }
            },
        )
    }
}

Every path must call finish, including dismissal — otherwise the caller stays suspended. Returning a sealed type rather than a Boolean makes that easier to get right:

sealed interface EditResult {
    data class Saved(val text: String) : EditResult
    data object Cancelled : EditResult
}

For something small, the overlay { } builder skips the class:

val toast = overlay<Unit> { navigator ->
    LaunchedEffect(Unit) {
        delay(2.seconds)
        navigator.finish(Unit)
    }
    Snackbar { Text("Saved") }
}

Showing one from a presenter

show suspends, so it needs a coroutine scope. Launch from the event sink:

class DetailPresenter(
    private val screen: DetailScreen,
    private val navigator: Navigator,
) : Presenter<DetailState> {

    @Composable
    override fun present(): DetailState {
        val overlayHost = LocalOverlayHost.current
        val scope = rememberCoroutineScope()

        return DetailState(screen.id) { event ->
            when (event) {
                DetailEvent.RequestDelete -> scope.launch {
                    val confirmed = overlayHost.show(ConfirmOverlay("Delete ${screen.id}?"))
                    if (confirmed) navigator.pop(DeletedResult(screen.id))
                }
            }
        }
    }
}

Because the scope comes from rememberCoroutineScope, navigating away cancels the coroutine and the overlay disappears with it.

OverlayEffect

To show something as a consequence of state rather than of a tap, use OverlayEffect, which is a LaunchedEffect with the host already in scope:

if (state.hasUnsavedChanges) {
    OverlayEffect(state.draftId) {
        val result = show(UnsavedChangesOverlay())
        ...
    }
}

One at a time

OverlayHost serialises calls to show. A second call waits for the first to finish rather than replacing it or throwing, so two screens racing to show a dialog queue up instead of fighting. currentOverlayData exposes what is showing right now, and is null when nothing is.

Cancellation

Cancelling the calling coroutine dismisses the overlay and clears the host. That falls out of show being a normal suspending function — no manual dismissal is needed when a screen goes away mid-decision.

Testing

orbit-test provides FakeOverlayHost, which records what was shown and lets the test answer:

@Test
fun deleteAsksForConfirmation() = runTest {
    val overlayHost = FakeOverlayHost()
    val navigator = FakeNavigator(DetailScreen("a"))

    ...

    val shown = overlayHost.awaitOverlay()
    assertTrue(shown is ConfirmOverlay)
    overlayHost.finish(true)

    assertEquals(DetailScreen("a"), navigator.awaitPop().poppedScreen)
}

Provide it through LocalOverlayHost in the test composition to intercept what the presenter shows.