Managing In-App Overlay States (Alerts, Dialogs, Bottom Sheets) in Android Composables

In-app overlays typically appear during API calls. Managing the overlay states can become challenging in MVI/MVVM architecture when an Android screen requires multiple API calls.

Vinod Kumar G
4 min read6 days ago
Photo by Angela Compagnone on Unsplash

What Are In-App Overlays?

In-app overlays are UI elements that appear on top of the app’s primary content, usually to provide additional information, options, or controls without navigating away from the current context. These overlays can take various forms, such as modal dialogs or full-screen overlays, depending on their function and the amount of content they need to display.

Advantages of In-App Overlays:

  • They enhance user interaction.
  • They minimize disruptions for users.
  • Overlays like modal dialogs are often used to prompt the user for confirmation or to input data in a constrained environment

Problem

Consider a scenario where a screen needs to display various in-app overlays, such as a loader (circular progress bar), an alert (for displaying API errors), and a bottom sheet, all on the existing screen.

Many developers typically structure the state class (e.g., HomeState) in MVI architecture as follows:

data class HomeState(
// ...................
// ...................
val isLoading: Boolean = false,
val hasError: Boolean = false,
val isShowingBottomSheet: Boolean = false
// ...................
// ...................
)

isLoading: This indicates whether a loader should be displayed on the screen.

hasError: This is used to display alerts in the event of an API failure.

isShowingBottomSheet: This controls the display of a bottom sheet.

Consider that an API is invoked within the ViewModel (HomeVM), and the following steps are necessary to update the UI state:

private var _state: MutableStateFlow<HomeState> = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state

init {
apiCall()
}

private fun apiCall() {
viewModelScope.launch {
//showing a loader
_state.update {
it.copy(
isLoading = true,
hasError = false,
isShowingBottomSheet = false
)
}
delay(5000)
//showing an error
_state.update {
it.copy(
isLoading = false,
hasError = true,
isShowingBottomSheet = false
)
}
delay(5000)
//showing bottom sheet UI
_state.update {
it.copy(
isLoading = false,
hasError = false,
isShowingBottomSheet = true
)
}
//hiding all the overlay views
_state.update {
it.copy(
isLoading = false,
hasError = false,
isShowingBottomSheet = false
)
}

}
}

In the apiCall() method, the state is updated to display a full-screen circular loader, an API alert, and a bottom sheet.

The respective screen’s composables are structured as follows:

@Composable
fun HomeScreen(viewModel: HomeVM) {
val state by viewModel.state.collectAsStateWithLifecycle()
HomeScreenContent(state = state, onEvent = { })
}

@Composable
private fun HomeScreenContent(
state: HomeState,
onEvent: () -> Unit
) {
Surface(Modifier.fillMaxSize()) {
// HomeScreenBody()
if (state.isLoading) {
LoaderUi()
}
if (state.hasError) {
AlertUi(onEvent)
}
if (state.isShowingBottomSheet) {
BottomSheetUi(onEvent)
}
}
}

LoaderUi:

@Composable
fun LoaderUi() {
Dialog(onDismissRequest = {}) {
CircularProgressIndicator(
modifier = Modifier.width(64.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 5.dp
)
}
}

AlertUi:

@Composable
fun AlertUi(onEvent: () -> Unit) {
AlertDialog(
text = { Text(text = "Show some error message") },
onDismissRequest = { /*TODO*/ },
confirmButton = {
Button(onClick = {
// do some operation, eg: onEvent(HomeEvent.OnCloseButtonClick)
}) { Text(text = "Close") }
})

}

BottomSheetUi:

@Composable
fun BottomSheetUi(onEvent: () -> Unit) {
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
)
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
repeat(10) {
Text(text = "Item $it")
}
}
) {

}
}

In the apiCall() method, the “hasError” and “isShowingBottomSheet” states are updated concurrently with the “isLoading” variable to display the loader UI. Similarly, “isShowingBottomSheet” and “isLoading” are adjusted together with “hasError” to trigger the display of an alert dialog. To hide these overlays from the screen, all three fields must be set to false. Any change to the overlay on the screen requires updating these three fields in the UI state to ensure the correct overlay is displayed.

App video Recording:

Note: Typically, the app displays only one overlay on the screen at a time.

By taking this advantage, we can update UI’s state as below.

data class HomeState(
// ...................
// ...................
val overlay: OverlayState = OverlayState.None
// ...................
// ...................
)

enum class OverlayState {
Loading,
None,
Error,
BottomSheet
}

The new OverlayState has been introduced in the code snippet above to manage the overlay state on the screen:

  • Loading: Displays the LoaderUi.
  • Error: Displays the AlertUi.
  • BottomSheet: Displays the bottom sheet.
  • None: No overlay is shown on the screen.

The revised composable code is outlined below:

@Composable
private fun HomeScreenContent(
state: HomeState,
onEvent: () -> Unit
) {
Surface(Modifier.fillMaxSize()) {
// HomeScreenBody()
HandleOverlayState(state, onEvent)
}
}

@Composable
private fun HandleOverlayState(
state: HomeState,
onEvent: (/*HomeEvent*/) -> Unit
) {
when (state.overlay) {
OverlayState.Loading -> LoaderUi()
OverlayState.Error -> AlertUi(onEvent)
OverlayState.BottomSheet -> BottomSheetUi(onEvent)
OverlayState.None -> Unit // no overlay on screen
}
}

The updated callApi() method is detailed below:

private fun apiCall() {
viewModelScope.launch {
//showing loader on screen
_state.update { it.copy(overlay = OverlayState.Loading) }

delay(2000)

//showing error on screen
_state.update { it.copy(overlay = OverlayState.Error) }

delay(2000)

//showing bottom sheet on screen
_state.update { it.copy(overlay = OverlayState.BottomSheet) }

delay(2000)

//hiding all the overlay views
_state.update { it.copy(overlay = OverlayState.None) }

}
}

Using the approach described above, the overlay on the screen can be modified by updating just one field (overlayState) in the UI state.

Benefits Compared to the Previous Approach:

  • This method improves readability.
  • It requires minimal code for state updates.
  • Simplifies writing unit tests for the apiCall() method.

Note: Depending on your requirements, you can opt for a sealed class or sealed interface instead of an enum for the overlayState. 😉

Conclusion:

To display a single overlay (from several available options) on the screen at any given time, it is more effective to use an enum instead of maintaining separate fields in the UI state for each overlay.

--

--

Vinod Kumar G

Mobile application developer @Deloitte | Kotlin | Swift | Java | Spring boot | Machine Learning | Deep Learning