-
-
Save rkam88/a0843ccbf3741cee85eff8354561b8f9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") } | |
} | |
} |
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
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
@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.