Sitemap

Manage Multiple Content, Actions and States ModalBottomSheetLayout Jetpack Compose

9 min readFeb 20, 2023

Multiple Content Management | BackHandler | Disable User Interaction

ModalBottomSheetLayout Jetpack Compose

Introduction

Jetpack Compose has many built-in composable and provides native ui development in a fast and robust way on Android OS. There are experimental features confuse developer’s mind, when trying to cover edge cases and providing additional features. Managing multiple content in ModalBottomSheetLayout is one of them. In this article we will create workarounds for common problems in ModalBottomSheetLayout.

Common Issues

We all know ModalBottomSheetLayout is an experimental composable for now. If you try to use it, you might figure out the difficulties and you might give up. Don’t Worry ! You will handle all of them after reading this article.

  • ModalBottomSheetLayout has a content parameter that requires an initial composable and throws exception when you don’t provide it.
  • Showing multiple bottom sheets in a ModalBottomSheetLayout content, requires handling custom states, updating contents and actions.
  • ModalBottomSheetLayout should be added to root of your screen composable. Otherwise, you will see meaningless things in your screen.
  • When you update states or call some functions using coroutines, your states may become corrupted if you don’t restrict user interaction.
  • Handling back-press functionality is required because ModalBottomSheetLayout does not hide itself when user clicks back.

Solutions-Step by Step

Under this title, we will solve the issues step by step that we defined above and we assume that you have a compose project and required dependencies added. Before writing code, please have a look at the image below, which covers some state change cases according to user actions, try to imagine lying structure under solutions.

1- Identify User Action

BottomSheetAction is an enum class for identifying user interactions and deciding visibility state on screen. Using these actions ModalBottomSheetLayout will show or hide itself automatically according to state changes.

// Identify user actions
enum class BottomSheetAction {
INITIAL, // initial case before user action
HIDE, // user wants to hide bottom sheet
SHOW, // user wants to show bottom sheet
TIMED // user wants to see and hide bottom sheet for some time, and disables screen interaction
}

2- Categorize Types of Bottom Sheet

BottomSheetType is an enum class for controlling content in ModalBottomSheetLayout. Composable contents for bottom sheets will be controlled and categorized by using these types.

// Categorize bottom sheets
enum class BottomSheetType {
LIST, // ListBottomSheetContent composable
ERROR, // ErrorBottomSheetContent composable
REDIRECT, // RedirectBottomSheetContent composable
INITIAL // InitialBottomSheetContent composable
}

3- Content Data of Each Type

BottomSheetContent is a sealed class that contains content data for different types of bottom sheets that we defined above. Each of these content types corresponds to a BottomSheetType. In each state, content data will be provided to bottom sheet by using these data classes.

// Content classes and object for each of BottomSheetType
sealed class BottomSheetContent {

// ERROR type content data
data class ErrorSheetContent(val message: String) : BottomSheetContent()

// REDIRECT type content data
data class RedirectSheetContent(val message: String, val redirect: String) : BottomSheetContent()

// LIST type content data
data class ListSheetContent(val title: String, val data: List<String>) : BottomSheetContent()

// INITAL type content data
object InitialSheetContent : BottomSheetContent()
}

4- Hold Current States of Content, Action and Type

ModalBottomSheetState is a data class that holds current states for BottomSheetContent, BottomSheetType and BottomSheetAction. All of them initialized to their own INITIAL state.

// Contains current states of user action, bottom sheet type and its content
// All of the values initialized to INITIAL state
data class ModalBottomSheetState(
val bottomSheetContent: BottomSheetContent = BottomSheetContent.InitialSheetContent,
val bottomSheetType: BottomSheetType = BottomSheetType.INITIAL,
val bottomSheetActionType: BottomSheetAction = BottomSheetAction.INITIAL
)

5- Update State in ViewModel

MainViewModel class is a ViewModel class that contains ModalBottomSheetState as StateFlow and updates it using updateBottomSheetState function. We will update and collect states using this class.

// ViewModel class for managing and containing ModalBottomSheetState
class MainViewModel : ViewModel() {

// Cover ModalBottomSheetState using StateFlow
// Using LiveData in here, is another option
private val _bottomSheetState: MutableStateFlow<ModalBottomSheetState> =
MutableStateFlow(ModalBottomSheetState())
val bottomSheetState: StateFlow<ModalBottomSheetState> = _bottomSheetState.asStateFlow()

// updates ModalBottomSheetState using a coroutine
fun updateBottomSheetState(
bottomSheetContent: BottomSheetContent,
bottomSheetType: BottomSheetType,
bottomSheetActionType: BottomSheetAction
) {
viewModelScope.launch {
_bottomSheetState.update {
it.copy(
bottomSheetActionType = bottomSheetActionType,
bottomSheetContent = bottomSheetContent,
bottomSheetType = bottomSheetType
)
}
}
}
}

6- Dummy Data

Let’s create dummy data for using in BottomSheetContent classes. Data may obtained from network calls or other resources. Also, other resources may decide the type of bottom sheet.

val errorMessage = "An unexpected error !"
val successMessage = "Operation Succeed !"
val redirectMessage = "Next"
val listHeader = "This is a list"
val listContent = listOf(
"Compose",
"Xml",
"Dialog",
"Kotlin",
"inline",
"in / out",
"fun",
"class",
"enum class",
"Hello World"
)

7- Bottom Sheet Content Composable of Each Type

Each bottom sheet content composable corresponds to a BottomSheetType. As we can see, InitialBottomSheetContent composable has a spacer in it because ModalBottomSheetLayout requires an anchored initial content in it, otherwise it throws an exception.

// Composable corresponding to LIST BottomSheetType
@Composable
fun ListBottomSheetContent(title: String, data: List<String>, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(
text = title,
style = TextStyle(fontSize = 16.sp, color = Color.Red),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(data) {
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
style = TextStyle(fontSize = 14.sp, color = Color.Blue)
)
Divider(color = Color.Gray, thickness = 1.dp)
}
}
}
}

// Composable corresponding to REDIRECT BottomSheetType
@Composable
fun RedirectBottomSheetContent(message: String, redirect: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = message,
style = TextStyle(fontSize = 14.sp, color = Color.Black)
)
Text(
text = redirect,
style = TextStyle(fontSize = 14.sp, color = Color.Black),
textDecoration = TextDecoration.Underline
)
}
}

// Composable corresponding to ERROR BottomSheetType
@Composable
fun ErrorBottomSheetContent(message: String, modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
text = message,
modifier = Modifier.fillMaxWidth(),
style = TextStyle(fontSize = 14.sp, color = Color.Black)
)
}
}

// Composable corresponding to INITIAL BottomSheetType
@Composable
fun InitialBottomSheetContent() {
Spacer(
modifier = Modifier
.width(1.dp)
.height(1.dp)
)
}

8- ModalBottomSheetLayout With Multiple Contents

  • Collect bottomSheetState to obtain state changes. At this point we can use collectAsStateWithLifecycle function to make our flow aware of lifecycle.
// collect state changes for your data using viewmodel
val uiState by viewModel.bottomSheetState.collectAsState()
  • Define a bottomSheetState value using rememberBottomSheetState function to make configuration on ModalBottomSheetLayout. By using this value, wrap the show and hide functions according to requirements. When configuring bottomSheetState, user interaction for REDIRECT type bottom sheet is disabled and maximum expand state restricted to HalfExpanded in confirmStateChange.
/* - create a value that remembers bottom sheet state 
- set this configuration to ModalBottomSheetLayout
- in confirmStateChange it disables user interaction for
REDIRECT bottom sheet type and disallows to full expanded format
*/
val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = {
when (uiState.bottomSheetType) {
BottomSheetType.REDIRECT -> false
else -> it != ModalBottomSheetValue.Expanded
}
},
skipHalfExpanded = false
)

// wrap show function in a coroutine
val showBottomSheet: () -> Unit = {
coroutineScope.launch {
bottomSheetState.show()
}
}

// wrap hide function in a coroutine
val hideBottomSheet: () -> Unit = {
coroutineScope.launch {
bottomSheetState.hide()
}
}

// wrap show and hide functions and create a delay between them
val timedNonCancellableShowHide: (millis: Long) -> Unit = {
coroutineScope.launch {
showBottomSheet()
delay(timeMillis = it)
hideBottomSheet()
}
}
  • Add a BackHandler composable function for controlling back-press functionality of Android OS. BackHandler is configured to hide bottom sheet except REDIRECT action type because this type blocks user interaction as we defined above and no need to handle it. Also BackHandler will be enabled when bottomSheetState.isVisible parameter is true.
//it will work when bottom sheet is visible
//if current format is not REDIRECT it will call hideBottomSheet()
BackHandler(bottomSheetState.isVisible) {
if (uiState.bottomSheetType != BottomSheetType.REDIRECT) {
hideBottomSheet()
}
}
  • According to BottomSheetAction type, ModalBottomSheetLayout needs to call show or hide function for achieving dynamically controlled system. To provide this functionality, add a side effect named LaunchedEffect, which triggers the content block in it when the BottomSheetAction type changes in state.
// calls the content in it when action type changes in state
LaunchedEffect(uiState.bottomSheetActionType) {
when (uiState.bottomSheetActionType) {
BottomSheetAction.INITIAL -> {}
BottomSheetAction.HIDE -> hideBottomSheet()
BottomSheetAction.SHOW -> showBottomSheet()
BottomSheetAction.TIMED -> timedNonCancellableShowHide(3000)
}
}
  • After user dismiss or hides manually bottom sheet, it should return to initial state, because you may accidentally corrupt content, action and type states, then it may cause an edge case. For fixing this issue, add another side effect named snapshotFlow, which converts a composable value to a flow.
// LaunchedEffect registers snapshotFlow for once
// snapShotFlow triggers content in it when currentValue of bottomSheetState changes
// if current value becomes hidden, it means user completed process and set bottom sheet to initial states
LaunchedEffect(Unit) {
snapshotFlow { bottomSheetState.currentValue }.collect {
if (it == ModalBottomSheetValue.Hidden) {
viewModel.updateBottomSheetState(
bottomSheetActionType = BottomSheetAction.INITIAL,
bottomSheetContent = BottomSheetContent.InitialSheetContent,
bottomSheetType = BottomSheetType.INITIAL
)
}
}
}
  • State changes for bottom sheet type and content should be handled in sheetContent parameter of ModalBottomSheetLayout. Check the state changes and show related screen to user with its content. Note that; don’t forget to set sheetState parameter, it provides remembered states and functionalities for ModalBottomSheetLayout.
ModalBottomSheetLayout(
sheetContent = {
// check current BottomSheetType
// show user related composable with casting BottomSheetContent to related model
when (uiState.bottomSheetType) {
BottomSheetType.LIST -> {
val content = uiState.bottomSheetContent as BottomSheetContent.ListSheetContent
ListBottomSheetContent(
title = content.title,
data = content.data,
modifier = Modifier
.height(300.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.ERROR -> {
val content = uiState.bottomSheetContent as BottomSheetContent.ErrorSheetContent
ErrorBottomSheetContent(
message = content.message,
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.REDIRECT -> {
val content =
uiState.bottomSheetContent as BottomSheetContent.RedirectSheetContent
RedirectBottomSheetContent(
message = content.message,
redirect = content.redirect,
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.INITIAL -> InitialBottomSheetContent()
}
},
sheetState = bottomSheetState, // don't forget to set this parameter
modifier = modifier
) {
// your screen content
// for example : Scaffold , Column etc.
}

9- Completed Code for MultipleBottomSheetContentScreen

Let’s have a look at completed code for MultipleBottomSheetContentScreen composable. We defined BottomSheetContent, BottomSheetType, BottomSheetAction and ViewModel classes above.

As a last note, for example user can change state by using a button. But, what if he/she clicks multiple times ? Multiple coroutines may launched and states may become corrupted. To prevent this issues use button’s enabled parameter and do not allow user to click it, before it becomes INITIAL state.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MultipleBottomSheetContentScreen(
modifier: Modifier = Modifier,
viewModel: MainViewModel = viewModel()
) {
val coroutineScope = rememberCoroutineScope()

val uiState by viewModel.bottomSheetState.collectAsState()

val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = {
when (uiState.bottomSheetType) {
BottomSheetType.REDIRECT -> false
else -> it != ModalBottomSheetValue.Expanded
}
},
skipHalfExpanded = false
)

val showBottomSheet: () -> Unit = {
coroutineScope.launch {
bottomSheetState.show()
}
}

val hideBottomSheet: () -> Unit = {
coroutineScope.launch {
bottomSheetState.hide()
}
}

val timedNonCancellableShowHide: (millis: Long) -> Unit = {
coroutineScope.launch {
showBottomSheet()
delay(timeMillis = it)
hideBottomSheet()
}
}

BackHandler(bottomSheetState.isVisible) {
if (uiState.bottomSheetType != BottomSheetType.REDIRECT) {
hideBottomSheet()
}
}

LaunchedEffect(uiState.bottomSheetActionType) {
when (uiState.bottomSheetActionType) {
BottomSheetAction.INITIAL -> {}
BottomSheetAction.HIDE -> hideBottomSheet()
BottomSheetAction.SHOW -> showBottomSheet()
BottomSheetAction.TIMED -> timedNonCancellableShowHide(3000)
}
}

LaunchedEffect(Unit) {
snapshotFlow { bottomSheetState.currentValue }.collect {
if (it == ModalBottomSheetValue.Hidden) {
viewModel.updateBottomSheetState(
bottomSheetActionType = BottomSheetAction.INITIAL,
bottomSheetContent = BottomSheetContent.InitialSheetContent,
bottomSheetType = BottomSheetType.INITIAL
)
}
}
}

ModalBottomSheetLayout(
sheetContent = {
when (uiState.bottomSheetType) {
BottomSheetType.LIST -> {
val content = uiState.bottomSheetContent as BottomSheetContent.ListSheetContent
ListBottomSheetContent(
title = content.title,
data = content.data,
modifier = Modifier
.height(300.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.ERROR -> {
val content = uiState.bottomSheetContent as BottomSheetContent.ErrorSheetContent
ErrorBottomSheetContent(
message = content.message,
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.REDIRECT -> {
val content =
uiState.bottomSheetContent as BottomSheetContent.RedirectSheetContent
RedirectBottomSheetContent(
message = content.message,
redirect = content.redirect,
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.padding(20.dp)
)
}
BottomSheetType.INITIAL -> InitialBottomSheetContent()
}
},
sheetState = bottomSheetState,
modifier = modifier
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Button(
onClick = {
viewModel.updateBottomSheetState(
bottomSheetContent = BottomSheetContent.ListSheetContent(
title = listHeader,
data = listContent
),
bottomSheetActionType = BottomSheetAction.SHOW,
bottomSheetType = BottomSheetType.LIST
)
},
enabled = uiState.bottomSheetActionType == BottomSheetAction.INITIAL
) {
Text(text = "List Bottom Sheet")
}

Button(
onClick = {
viewModel.updateBottomSheetState(
bottomSheetContent = BottomSheetContent.ErrorSheetContent(message = errorMessage),
bottomSheetActionType = BottomSheetAction.SHOW,
bottomSheetType = BottomSheetType.ERROR
)
},
enabled = uiState.bottomSheetActionType == BottomSheetAction.INITIAL
) {
Text(text = "Error Bottom Sheet")
}

Button(
onClick = {
viewModel.updateBottomSheetState(
bottomSheetContent = BottomSheetContent.RedirectSheetContent(
message = successMessage,
redirect = redirectMessage
),
bottomSheetActionType = BottomSheetAction.TIMED,
bottomSheetType = BottomSheetType.REDIRECT
)
},
enabled = uiState.bottomSheetActionType == BottomSheetAction.INITIAL
) {
Text(text = "Redirect Non-Cancellable Bottom Sheet")
}
}
}
}
Happy End :)

Conclusion

In this article, we solved issues about ModalBottomSheetLayout Jetpack Compose. I hope, this article will be a guide for your implementations. Please don’t hesitate to ask your questions in comments section below. Follow me on LinkedIn and Github for more…

--

--

Emre UYSAL
Emre UYSAL

Written by Emre UYSAL

Android Developer & Software Engineer

No responses yet