I want to hide the bottom sheet from inside a View...
# compose
l
I want to hide the bottom sheet from inside a ViewModel (after a backend call succeeded). To do this, I need to emit a
StateFlow
from the ViewModel which contains a boolean property
showBottomSheet
. In my screen's composable, I can then remember (and reinitialize) the bottom sheet state like this:
Copy code
val modalBottomSheetState = rememberModalBottomSheetState(if (screenState.showBottomSheet) Expanded else Hidden)
That takes care of hiding the bottom sheet. But how do I keep the
showBottomSheet
state in the ViewModel synchronized with the composable, when e.g. the user swipes down the bottom sheet? For this I included a callback in the composable:
onBottomSheetDismissed
. I called this callback in the
confirmStateChange
lambda parameter of `rememberModalBottomSheetState()`:
Copy code
// Composable

val modalBottomSheetState = rememberModalBottomSheetState(
    initialValue = if (screenState.showBottomSheet) Expanded else Hidden,
    confirmStateChange = {
        onBottomSheetDismissed()
        true
    }
)

// ViewModel

val screenState: StateFlow<ScreenState> = _screenState.asStateFlow()
private val _screenState: MutableStateFlow<ScreenState>

fun onBottomSheetDismissed() {
    _screenState.update { it.copy(showBottomSheet = false) }
}
But it appears that this callback is called very early in the dragging process, and my
showBottomSheet
variable in the ViewModel gets reset to
false
as soon as the user even drags the bottom sheet one pixel down and the bottom sheet immediately disappears (see video in thread) Is there a better way to handle opening/closing the bottom sheet from a viewmodel and keeping the state synchronized between the two?
đź§µ 1
o
I was trying to sync those two in the ModalSheet lib, where we would like to have an easy API with only "visible" state (+ event lambda for update), but build on top of SheetState from the Compose. In our inplementation we always start with a Hidden state, and use methods to show() / hide() the sheet when the boolean state changes. You can check the code here, maybe it may help for your case: https://github.com/oleksandrbalan/modalsheet/blob/main/modalsheet/src/main/java/eu/wewox/modalsheet/ModalSheet.kt#L118
l
Thank you very much, I will take a look at it!
I tried to use the
LaunchedEffect
block from your code, but generally I still have the same problem -
onVisibleChange()
callback is called too soon (as soon as the user starts dragging the finger down). So the callback to the viewmodel gets called immediately => the viewmodel emits the updated state (to hide the bottomsheet) => the bottomsheet disappears even though the user didn't finish dragging the finger down (or even moved his finger up again because he changed his mind).
I see you actually wrap your
ModalBottomSheetLayout
into a
FullScreenPopup
, which gets the
onDismiss
callback. What is the reason for this?
o
the bottomsheet disappears
Hmm 🤔 Maybe you are removing the modal sheet from the composition when
showBottomSheet
flag is set to false? Could you share a code where
ModalBottomSheetLayout
is used?
FullScreenPopup
… What is the reason for this?
The popup is a “trick” how to render the ModalSheet above all other UI. See Motivation &amp; Solution sections.
l
Ok, thanks for your explanation. I change the bottomsheet state immediately when the
showBottomSheet
flag is
false
, yes:
Copy code
val modalBottomSheetState = rememberModalBottomSheetState(
        initialValue = if (systemScreenState.showSensorSettings) Expanded else Hidden,
    )
o
And what if you try to set it always to
Hidden
and make it be driven with the
LaunchedEffect
and it’s
.show() / .hide()
?
l
Ah sorry, I actually started with the
initialValue
being always hidden, but it didn't work as well. The problem is simply the
confirmStateChange
callback is coming before the state change has been triggered, so the viewmodel state updates (hence emits) too early.
I now fixed it using a
DisposableEffect
, which is kind of hacky, but works perfectly. The fix is based on https://stackoverflow.com/a/69052933/4990683
Copy code
if (modalBottomSheetState.currentValue != ModalBottomSheetValue.Hidden) {
    DisposableEffect(Unit) {
        onDispose {
            onBottomSheetDismissed()
        }
    }
}
This means that the
DisposableEffect
is added to the composition once the bottomsheet is shown. But once the bottomsheet is hidden, the
DisposableEffect
is removed from the composition, which triggers its
onDispose
method. In this way my viewmodel state is updated after the bottom sheet got dismissed.
The alternative described in the Stackoverflow answer is to subscribe to snapshot changes inside a
LaunchedEffect
.
o
It is pretty weird, but only when I place the
visible
state and
ModalBottomSheetState
inside one Composable I get the behaviour you are describing 🤔 But when I extract the
visible
state to the outer Composable it suddenly works as I would expect 🤔
Copy code
@Composable
private fun Demo() {
    var visible by remember { mutableStateOf(false) }

    DemoContent(
        visible = visible,
        onVisibleChange = { visible = it }
    )
}

@Composable
fun DemoContent(
    visible: Boolean,
    onVisibleChange: (Boolean) -> Unit,
) {
    val sheetState = rememberModalBottomSheetState(
        skipHalfExpanded = true,
        initialValue = ModalBottomSheetValue.Hidden,
        confirmStateChange = {
            onVisibleChange(it == ModalBottomSheetValue.Expanded)
            true
        }
    )

    LaunchedEffect(visible) {
        if (visible) {
            sheetState.show()
        } else {
            sheetState.hide()
        }
    }

    ModalBottomSheetLayout(
        sheetState = sheetState,
        sheetContent = {
            Box(
                modifier = Modifier
                    .height(500.dp)
                    .fillMaxWidth()
                    .background(Color.Cyan)
            )
        }
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()
        ) {
            Button(onClick = { onVisibleChange(true) }) {
                Text("Open")
            }
        }
    }
}
l
Interesting
In the long run I want to migrate to Accompanist Navigation Material (or even Compose Destinations), and there I would have to do it completely differently anyway. The
DisposableEffect
now is just a temporary solution.
This what you're talking about could be something in compose world called a deferred read.
o
Okay 👍
deferred read
Yea, I think it is due to changing the value right in the
confirmStateChange
lambda 🤔
l
Which prevents recomposition as long as the value is not read inside of the composable
956 Views