12

I want to implement a screen which can show two different bottom sheets. Since ModalBottomSheetLayout only has a slot for one sheet I decided to change the sheetContent of the ModalBottomSheetLayout dynamically using a selected state when I want to show either of the two sheets (full code).

val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)

val (selected, setSelected) = remember(calculation = { mutableStateOf(0) })

ModalBottomSheetLayout(sheetState = sheetState, sheetContent = {
    when (selected) {
       0 -> Layout1()
       1 -> Layout2()
    }
}) {
   Content(sheetState = sheetState, setSelected = setSelected)
}

This works fine for very similar sheets, but as soon as you add more complexity to either of the two sheet layouts the sheet will not show when the button is pressed for the first time, it will only show after the button is pressed twice as you can see here:

2

Here you can find a reproducible example

Yannick
  • 4,833
  • 8
  • 38
  • 63

4 Answers4

7

I had a similar usecase, where I needed to show 2-3 stacked bottomsheets. I ended up copying large part of Compose BottomSheet and added the desired behavior:

enum class BottomSheetValue { SHOWING, HIDDEN }

@Composable
fun BottomSheet(
        parentHeight: Int,
        topOffset: Dp = 0.dp,
        fillMaxHeight: Boolean = false,
        sheetState: SwipeableState<BottomSheetValue>,
        shape: Shape = bottomSheetShape,
        backgroundColor: Color = MaterialTheme.colors.background,
        contentColor: Color = contentColorFor(backgroundColor),
        elevation: Dp = 0.dp,
        content: @Composable () -> Unit
) {
    val topOffsetPx = with(LocalDensity.current) { topOffset.roundToPx() }
    var bottomSheetHeight by remember { mutableStateOf(parentHeight.toFloat())}

    val scrollConnection = sheetState.PreUpPostDownNestedScrollConnection

    BottomSheetLayout(
        maxHeight = parentHeight - topOffsetPx,
        fillMaxHeight = fillMaxHeight
    ) {
        val swipeable = Modifier.swipeable(
            state = sheetState,
            anchors = mapOf(
                parentHeight.toFloat() to BottomSheetValue.HIDDEN,
                parentHeight - bottomSheetHeight to BottomSheetValue.SHOWING
            ),
            orientation = Orientation.Vertical,
            resistance = null
        )

        Surface(
            shape = shape,
            color = backgroundColor,
            contentColor = contentColor,
            elevation = elevation,
            modifier = Modifier
                .nestedScroll(scrollConnection)
                .offset { IntOffset(0, sheetState.offset.value.roundToInt()) }
                .then(swipeable)
                .onGloballyPositioned {
                    bottomSheetHeight = it.size.height.toFloat()
                },
        ) {
            content()
        }
    }
}


@Composable
private fun BottomSheetLayout(
        maxHeight: Int,
        fillMaxHeight: Boolean,
        content: @Composable () -> Unit
) {
    Layout(content = content) { measurables, constraints ->
        val sheetConstraints =
            if (fillMaxHeight) {
                constraints.copy(minHeight = maxHeight, maxHeight = maxHeight)
            } else {
                constraints.copy(maxHeight = maxHeight)
            }

        val placeable = measurables.first().measure(sheetConstraints)

        layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }
}

TopOffset e.g. allows to place the bottomSheet below the AppBar:

BoxWithConstraints {
 BottomSheet(
                parentHeight = constraints.maxHeight,
                topOffset = with(LocalDensity.current) {56.toDp()}
                fillMaxHeight = true,
                sheetState = yourSheetState,
            ) {
                content()
            }
}
jns
  • 6,017
  • 2
  • 23
  • 28
  • Thank you very much. Can you give me a hint of what you have changed to make it work? – Yannick Mar 02 '21 at 13:49
  • 1
    Unfortunately, I cannot copy it since it contains a lot of internal or private functions and fields – Yannick Mar 02 '21 at 17:28
  • I think the issue is caused by setting a new sheetContent and changing the sheetState at the same time. You can see that, when you add a log message to sheetState. When the sheetState is updated delayed i.e. after the state is changed internally by ModalBottomSheet (haven't found where this is happening, yet) it works. – jns Mar 02 '21 at 17:56
  • Any updates on this? I have not found anything myself but I think this should be possible in Jetpack Compose without any hassle. – Yannick Mar 09 '21 at 16:50
  • Here is a bug report which describes your issue and the reason for it: https://issuetracker.google.com/issues/181593642 (maybe it was filed by you?) – jns Mar 09 '21 at 20:51
  • Thank you. It was not filed by me but I will add my case to it – Yannick Mar 09 '21 at 21:48
  • Could you add correct imports to your answer? `rememberState` is missing for me (I'm using rc1 version of JetpackCompose) – Dmytro Rostopira Jul 12 '21 at 13:28
  • rememberState is my custom shortcut (forgot to replace it) for remember {mutableStateOf()} – jns Jul 12 '21 at 13:42
5

I implemented it like this. It looks pretty simple, but I still could not figure out how to pass the argument to "mutableStateOf ()" directly, I had to create a variable "content"

fun Screen() {
    val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
    val scope = rememberCoroutineScope()
    val content: @Composable (() -> Unit) = { Text("NULL") }
    var customSheetContent by remember { mutableStateOf(content) }
    ModalBottomSheetLayout(
        sheetState = bottomSheetState,
        sheetContent = {
            customSheetContent()
        }
    ) {
        Column {
        Button(
            onClick = {
                customSheetContent = { SomeComposable1() }
                scope.launch { bottomSheetState.show() }
        }) {
         Text("First Button")
        }

        Button(
            onClick = {
                customSheetContent = { SomeComposable2() }
                scope.launch { bottomSheetState.show() }
        }) {
         Text("Second Button")
        }
    }
    }
Tim Yumalin
  • 420
  • 5
  • 14
3

I wanted to implement the same thing and because of the big soln, I wrote a post on dev.to that solves this problem, Here is the link

David Ibrahim
  • 2,777
  • 4
  • 17
  • 41
  • Seems that, whenever the BottomSheet's content height changes, the first try when trying to open it will make it close quickly again. – nyx69 May 10 '21 at 15:59
  • 1
    Okie I will fix it in the post and the repo – David Ibrahim May 10 '21 at 16:14
  • 1
    done, you can check, the trick is to make the current screen state nullable and when the sheet is closed set it to null – David Ibrahim May 10 '21 at 16:27
  • 1
    Another question: Any idea on how to achieve the same using a ModalBottomSheetLayout ? – nyx69 May 10 '21 at 18:35
  • I think you could use the same idea ... By saving the bottom sheet current state and passing it to the sheet layout – David Ibrahim May 10 '21 at 19:16
  • 1
    Unfortunately this doesn't seem to work out like that in ModalBottomSheetLayout - especially once you set the state to Null, as the anchor won't be given then, resulting in "The target value must have an associated anchor" :/ – nyx69 May 11 '21 at 16:49
  • 2
    I like this approach, thanks for that post. I also need to use ModalBottomSheetLayout and i resolved the IllegalStateException by adding minHeight modifier to sheet content. Sheet content must have at least half of the screen size height to show collapsed state. – goofy Aug 31 '21 at 14:04
0

This solution works with N amount of bottom sheets, and even works if you want to stack them, here is the implementation:

typealias BottomSheetControl = Pair<ModalBottomSheetState, @Composable ColumnScope.() -> Unit> 

@Composable
fun HeaderContentMultipleBottomSheetsTemplate(
    finalContent: @Composable () -> Unit,
    sheetsShape: Shape = ServyUIShapesDefaults.RoundedRectangle,
    vararg sheets: BottomSheetControl
) {
    HeaderContentBottomSheetsTemplateImpl(
        finalContent = finalContent,
        sheetsShape = sheetsShape,
        sheets = sheets,
    )
}

@Composable
private fun HeaderContentBottomSheetsTemplateImpl(
    modifier: Modifier = Modifier,
    finalContent: @Composable () -> Unit,
    scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
    sheetsShape: Shape = ServyUIShapesDefaults.RoundedRectangle,
    vararg sheets: BottomSheetControl
) {
    if (sheets.isNotEmpty()) {
        ModalBottomSheetLayout(
            modifier = modifier,
            sheetState = sheets[0].first,
            sheetContent = sheets[0].second,
            sheetShape = sheetsShape,
            scrimColor = scrimColor
        ) {
            HeaderContentBottomSheetsTemplateImpl(
                finalContent = finalContent,
                sheets = sheets.sliceArray(1 until sheets.size)
            )
        }
    } else {
        finalContent() // Here is your Screen Content
    }
}

For the implementation you need to have this :

// The sheet state, you can manipulate it whatever you want.
val testSheetState= rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
        animationSpec = tween(durationMillis = 100),
        confirmValueChange = { it != ModalBottomSheetValue.HalfExpanded },
        skipHalfExpanded = true,
    )

// The sheet content.
@Composable
fun TestSheet(): @Composable ColumnScope.() -> Unit = {
    Text(text = "LOLLL")
}

// The Pair of state and content 
val test: BottomSheetControl = Pair(testSheetState, TestSheet())

And finally you can just:

HeaderContentMultipleBottomSheetsTemplate(
    finalContent = {
        // Here the content of your screen (in ColumnScope)
    },
    sheets = arrayOf(test)
)

Pd: The order of the items in the vararg argument "sheets" define the level of each one, if you want to stack them, please write in nested order.

Nojipiz
  • 33
  • 6