Skip to content

Instantly share code, notes, and snippets.

@rkam88
Last active January 7, 2024 23:58
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rkam88/a0843ccbf3741cee85eff8354561b8f9 to your computer and use it in GitHub Desktop.
Save rkam88/a0843ccbf3741cee85eff8354561b8f9 to your computer and use it in GitHub Desktop.
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.SwipeableState
import androidx.compose.material.Text
import androidx.compose.material.contentColorFor
import androidx.compose.material.swipeable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.launch
//region SheetState
// Possible sheet positions
enum class SheetPosition { HIDDEN, PARTIALLY_EXPANDED, EXPANDED }
@ExperimentalMaterialApi
class SheetState(
val skipPartiallyExpanded: Boolean,
initialPosition: SheetPosition = SheetPosition.HIDDEN,
confirmPositionChange: (SheetPosition) -> Boolean = { true },
) {
companion object {
@Suppress("RemoveExplicitTypeArguments")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmPositionChange: (SheetPosition) -> Boolean,
) = Saver<SheetState, SheetPosition>(
save = { sheetState: SheetState -> sheetState.swipeableState.currentValue },
restore = { savedValue: SheetPosition ->
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialPosition = savedValue,
confirmPositionChange = confirmPositionChange
)
}
)
}
val swipeableState: SwipeableState<SheetPosition> = SwipeableState(
initialValue = initialPosition,
confirmStateChange = confirmPositionChange,
)
}
@ExperimentalMaterialApi
@Composable
fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmPositionChange: (SheetPosition) -> Boolean = { true },
): SheetState {
return rememberSaveable(
skipPartiallyExpanded, confirmPositionChange,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmPositionChange = confirmPositionChange,
)
) {
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialPosition = SheetPosition.HIDDEN,
confirmPositionChange = confirmPositionChange,
)
}
}
//endregion
//region ModalBottomSheet
private const val UNKNOWN = -1
@ExperimentalMaterialApi
@Composable
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
sheetState: SheetState = rememberSheetState(),
shape: Shape = RectangleShape,
color: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(color),
border: BorderStroke? = null,
content: @Composable () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val closeSheet: () -> Unit = {
coroutineScope.launch { sheetState.swipeableState.animateTo(SheetPosition.HIDDEN) }
}
Popup(
onDismissRequest = closeSheet,
properties = PopupProperties(focusable = true),
) {
BoxWithConstraints(
contentAlignment = Alignment.BottomCenter,
modifier = Modifier.fillMaxSize()
) {
var sheetHeightPx by remember(sheetState) { mutableStateOf(UNKNOWN) }
val anchors: Map<Float, SheetPosition> by remember(
sheetHeightPx,
constraints.maxHeight,
sheetState,
) {
mutableStateOf(
if (sheetHeightPx == UNKNOWN) {
emptyMap()
} else {
buildMap {
put(0f, SheetPosition.EXPANDED)
if (constraints.maxHeight / 2 < sheetHeightPx && sheetState.skipPartiallyExpanded.not()) {
put((sheetHeightPx - constraints.maxHeight / 2).toFloat(), SheetPosition.PARTIALLY_EXPANDED)
}
put(sheetHeightPx.toFloat(), SheetPosition.HIDDEN)
}
}
)
}
ScrimBackground(
swipeableState = sheetState.swipeableState,
onClick = closeSheet
)
Surface(
shape = shape,
color = color,
contentColor = contentColor,
border = border,
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures { offset ->
if (offset.y < sheetState.swipeableState.offset.value) closeSheet()
}
}
.onPlaced { sheetHeightPx = it.size.height }
.let {
if (sheetHeightPx == UNKNOWN) {
it
} else {
it
.swipeable(
state = sheetState.swipeableState,
anchors = anchors,
orientation = Orientation.Vertical
)
.offset {
IntOffset(
x = 0,
y = sheetState.swipeableState.offset.value
.toInt()
.coerceIn(0, sheetHeightPx)
)
}
}
}
.nestedScroll(rememberSheetContentNestedScrollConnection(sheetState.swipeableState))
) {
content()
}
// Show the sheet with animation once we know it's size
LaunchedEffect(sheetHeightPx) {
if (sheetHeightPx != UNKNOWN && sheetState.swipeableState.currentValue == SheetPosition.HIDDEN) {
val target = if (anchors.containsValue(SheetPosition.PARTIALLY_EXPANDED)) {
SheetPosition.PARTIALLY_EXPANDED
} else {
SheetPosition.EXPANDED
}
sheetState.swipeableState.animateTo(target)
}
}
}
}
// Prevent the sheet from being dismissed during the initial animation when the state is COLLAPSED
var wasSheetShown by remember(sheetState) { mutableStateOf(false) }
LaunchedEffect(sheetState.swipeableState.currentValue) {
when (sheetState.swipeableState.currentValue) {
SheetPosition.PARTIALLY_EXPANDED -> wasSheetShown = true
SheetPosition.EXPANDED -> wasSheetShown = true
SheetPosition.HIDDEN -> if (wasSheetShown) onDismissRequest()
}
}
}
//endregion
//region ScrimBackground
private const val SCRIM_ALPHA = 0.32f
@ExperimentalMaterialApi
@Composable
private fun ScrimBackground(
swipeableState: SwipeableState<SheetPosition>,
onClick: () -> Unit,
) {
val showScrim by remember {
derivedStateOf { swipeableState.targetValue != SheetPosition.HIDDEN }
}
val scrimAlpha by animateFloatAsState(targetValue = if (showScrim) SCRIM_ALPHA else 0f)
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) { detectTapGestures { onClick() } }
) {
drawRect(color = Color.Black, alpha = scrimAlpha)
}
}
//endregion
//region NestedScroll
@ExperimentalMaterialApi
@Composable
private fun rememberSheetContentNestedScrollConnection(
sheetPosition: SwipeableState<SheetPosition>,
): NestedScrollConnection = remember(sheetPosition) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
// if trying to scrolling upwards, drag the sheet first
return if (available.y < 0 && source == NestedScrollSource.Drag) {
sheetPosition.performDrag(available.y).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
// If child scrolled to it's end, consume the remaining scroll but check that the
// source is Drag to ignore fling events (i.e. after flinging to the start of the list)
return if (source == NestedScrollSource.Drag) {
sheetPosition.performDrag(available.y).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
// If flinging upwards and sheet is not at the top - consume the event
return if (available.y < 0 && sheetPosition.offset.value > 0f) {
sheetPosition.animateTo(sheetPosition.targetValue)
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
sheetPosition.animateTo(sheetPosition.targetValue)
return available
}
private fun Float.toOffset() = Offset(0f, this)
}
}
//endregion
@OptIn(ExperimentalMaterialApi::class)
@Preview
@Composable
private fun ModalBottomSheetPreview() {
var showSheetSmall by rememberSaveable { mutableStateOf(false) }
var showSheetLarge by rememberSaveable { mutableStateOf(false) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Button(onClick = { showSheetSmall = true }) { Text("Show sheet (small)") }
Button(onClick = { showSheetLarge = true }) { Text("Show sheet (full screen)") }
}
if (showSheetSmall) {
ModalBottomSheet(
onDismissRequest = { showSheetSmall = false },
sheetState = rememberSheetState(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
SheetContent(items = 6)
}
}
if (showSheetLarge) {
ModalBottomSheet(
onDismissRequest = { showSheetLarge = false },
sheetState = rememberSheetState(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
SheetContent(items = 101)
}
}
}
}
@Composable
private fun SheetContent(items: Int) {
Column(
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
repeat(times = items) { Text("Sheet item number $it") }
}
}
@minas1
Copy link

minas1 commented Sep 26, 2023

Thanks for this implementation, but I have noticed a small visual bug - the sheet is cut off at the bottom and partially hides the device's UI controls. See the attached image.

Screenshot_1695736947

@rkam88
Copy link
Author

rkam88 commented Sep 26, 2023

@minas1 Hi! Thanks for the comment - this behaviour is caused by Popup and it also happens with the official Material 3 bottom sheet. Unfortunately, I wasn't able to find a workaround for this.

@dannydevww
Copy link

dannydevww commented Jan 6, 2024

Very Neat! Can this be used with a BottomSheetScaffold? I'm having this weird bug in which the bottom sheet opens and closes immediately on subsequent click for expansion

@rkam88
Copy link
Author

rkam88 commented Jan 7, 2024

Very Neat! Can this be used with a BottomSheetScaffold? I'm having this weird bug in which the bottom sheet opens and closes immediately on subsequent click for expansion

@dannydevww Thank you! Unfortunately I didn't try myself. The component was made as a Material 2 version of the Material 3 component so I can't offer any advice on the integration.

As a side note, If you've already switched to Material 3 - I'd recommend using the library version of the modal sheet as it does the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment