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¶
Wrap your content once, above the navigation host:
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.