Skip to content

Instantly share code, notes, and snippets.

@mitchtabian

mitchtabian/README.md

Last active Apr 16, 2021
Embed
What would you like to do?

I couldn't get BottomSheetScaffold to work properly when used on top of a GoogleMap (in an AndroidView). So I made my own.

It is not thoroughly tested but seems to be working well enough.

Create a bottom sheet like this: https://imgur.com/gallery/FTWS8Uc

Usage

Keep reference to SimpleBottomSheetManager in viewmodel

class YourViewModel: ViewModel(){
  ...
  val bottomSheetManager = SimpleBottomSheetManager()
  ...
}

Add to your composable

@Composable
fun SomeComposable(
  bottomSheetManager: BottomSheetManager,
){
  Column{
      Text("Dunno some random text")
      Text("Hey look more random text")
  }
  BuildSheet(
    isVisible = true,
    bottomSheetManager = bottomSheetManager,
    bottomContainerData = SimpleBottomSheet.BottomContainerData(
        title = "Bottom Title",
        subText1 = "Sub-text numero 1",
        subText2 = "Sub-text TWO",
        subText3 = "A third sub text cuz why not?",
    ),
    titleContainerData = SimpleBottomSheet.TitleContainerData(
        title = "BIG TITLE",
        subtitle = "subtitle",
        iconResource = R.drawable.your_drawable,
        onClickIcon = {
            // this will execute when the icon is clicked
        }
    )
  ) 
}
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
object SimpleBottomSheet {
@Composable
fun BuildSheet(
isVisible: Boolean,
bottomSheetManager: SimpleBottomSheetManager,
bottomContainerData: BottomContainerData,
titleContainerData: TitleContainerData,
){
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val screenHeight = remember{ mutableStateOf(0f) }
// get screen height and initialize (once)
if(screenHeight.value == 0f){
BoxWithConstraints {
screenHeight.value = maxHeight.value
bottomSheetManager.setup(screenHeight.value)
}
}
val paddingHorizontal = remember{8}
Box(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.alpha(if(isVisible) 1f else 0f)
){
val modifier = Modifier
.padding(start = paddingHorizontal.dp, end = paddingHorizontal.dp)
.fillMaxWidth()
.background(color = Color.White)
Surface(
modifier = Modifier
.offset(y = bottomSheetManager.sheetPosition.value.dp)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consumeAllChanges()
bottomSheetManager.onDrag(dragAmount.y)
},
onDragEnd = {
coroutineScope.launch{
bottomSheetManager.onDragEnd()
}
},
onDragStart = {
bottomSheetManager.onDragStart()
},
)
}
,
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
) {
Box(
modifier = Modifier
.onGloballyPositioned { layoutCoordinates ->
val height = with(density) { (layoutCoordinates.size.height).toDp() }
bottomSheetManager.setTitleContainerHeight(height.value)
}
){
BottomSheetTitleContainer(
titleContainerData = titleContainerData,
modifier = modifier
.padding(top = 16.dp, bottom = 16.dp)
,
)
}
}
Surface(
modifier = Modifier
.offset(y = bottomSheetManager.sheetPosition.value.dp)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consumeAllChanges()
bottomSheetManager.onDrag(dragAmount.y)
},
onDragEnd = {
coroutineScope.launch {
bottomSheetManager.onDragEnd()
}
},
onDragStart = {
bottomSheetManager.onDragStart()
}
)
}
,
) {
Box(
modifier = Modifier
.onGloballyPositioned { layoutCoordinates ->
val height = with(density) { (layoutCoordinates.size.height).toDp() }
bottomSheetManager.setBottomContainerHeight(height.value)
}
){
BottomSheetBottomContainer(
modifier = modifier,
bottomContainerData = bottomContainerData
)
}
}
}
}
bottomSheetManager.animate()
}
@Composable
private fun BottomSheetBottomContainer(
modifier: Modifier,
bottomContainerData: BottomContainerData,
){
Column(
modifier = modifier
.padding(bottom = 16.dp)
){
Divider(
color = Color(0xFFc2c2c2),
thickness = 1.dp
)
Column {
Text(
modifier = Modifier
.fillMaxWidth(.85f)
.padding(top = 8.dp)
,
text = bottomContainerData.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.subtitle1,
)
bottomContainerData.subText1?.let {
Text(
modifier = Modifier
.fillMaxWidth(.85f)
.padding(top = 8.dp)
,
text = it ,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
)
}
}
Column {
bottomContainerData.subText2?.let {
Text(
modifier = Modifier
.fillMaxWidth(.85f)
.padding(top = 8.dp)
,
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
)
}
bottomContainerData.subText3?.let {
Text(
modifier = Modifier
.fillMaxWidth(.85f)
.padding(top = 8.dp)
,
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
)
}
}
}
}
@Composable
private fun BottomSheetTitleContainer(
titleContainerData: TitleContainerData,
modifier: Modifier,
){
val verticalPadding = remember {8}
Column (modifier = modifier){
Row{
Text(
modifier = Modifier
.fillMaxWidth(if(titleContainerData.iconResource != null) .85f else 1f)
,
text = titleContainerData.title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
)
titleContainerData.iconResource?.let { drawable ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement= Arrangement.End
){
Icon(
modifier = Modifier
.width(35.dp)
.height(35.dp)
.clickable {
if(titleContainerData.onClickIcon != null){
titleContainerData.onClickIcon.invoke()
}
}
,
painter = painterResource(id = drawable),
contentDescription = "Bottom Sheet Icon",
)
}
}
}
titleContainerData.subtitle?.let {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = verticalPadding.dp)
,
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
)
}
}
}
data class TitleContainerData(
val title: String,
val subtitle: String? = null,
@DrawableRes val iconResource: Int? = null,
val onClickIcon: (() -> Unit)? = null,
)
data class BottomContainerData(
val title: String,
val subText1: String? = null,
val subText2: String? = null,
val subText3: String? = null,
)
}
import androidx.compose.animation.core.*
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
class SimpleBottomSheetManager {
// the resting state (collapsed or expanded)
val state: MutableState<SimpleBottomSheetState> = mutableStateOf(Collapsed)
// is the user currently dragging the sheet?
private val isDragging: MutableState<Boolean> = mutableStateOf(false)
// how far did they drag it in this particular drag session?
private val totalDragAmount: MutableState<Float> = mutableStateOf(0f)
// Is an animation currently underway? (Started when a drag has stopped)
private val isAnimating: MutableState<Boolean> = mutableStateOf(false)
// The offset to be animated to
private val offset: MutableState<Animatable<Offset, AnimationVector2D>?> = mutableStateOf(null)
private val screenHeight: MutableState<Float> = mutableStateOf(0f)
// the thing that we track and animate to move the sheet
val sheetPosition: MutableState<Float> = mutableStateOf(0f)
/**
* The sheet is divided into two parts:
* 1. titleContainer (above the fold)
* 2. bottomContainer (below the fold)
*/
val titleContainerHeight: MutableState<Float> = mutableStateOf(0f)
val bottomContainerHeight: MutableState<Float> = mutableStateOf(0f)
// Collapse threshold must be a function of the total space occupied
val collapseThreshold: MutableState<Float> = mutableStateOf(80f)
private fun calculateCollapseThreshold(){
collapseThreshold.value = (titleContainerHeight.value + bottomContainerHeight.value) * 0.3f
}
fun setBottomContainerHeight(value: Float){
bottomContainerHeight.value = value
calculateCollapseThreshold()
}
fun setTitleContainerHeight(value: Float){
titleContainerHeight.value = value
calculateCollapseThreshold()
}
fun animate(){
if(!isDragging.value){
if(isAnimating.value){
offset.value?.value?.y?.run{
sheetPosition.value = this
}
}
else{
validateSheetPosition()
}
}
}
/**
* When selecting different outages we must validate the position of the sheet after new data is set.
*/
private fun validateSheetPosition(){
if(state.value == Collapsed){
sheetPosition.value = screenHeight.value - titleContainerHeight.value
}
else if(state.value == Expanded){
sheetPosition.value = screenHeight.value - (bottomContainerHeight.value + titleContainerHeight.value)
}
}
/**
* Enforce the boundaries on the sheet while a drag is occurring
*/
private fun enforceSheetBoundaries(){
if (sheetPosition.value <= (screenHeight.value - (bottomContainerHeight.value + titleContainerHeight.value))) {
sheetPosition.value = screenHeight.value - (bottomContainerHeight.value + titleContainerHeight.value)
}
if (sheetPosition.value > (screenHeight.value - titleContainerHeight.value)) {
sheetPosition.value = screenHeight.value - titleContainerHeight.value
}
}
fun onDrag(dragAmount: Float){
totalDragAmount.value += dragAmount
sheetPosition.value += dragAmount
enforceSheetBoundaries()
}
fun onDragStart(){
totalDragAmount.value = 0f
isDragging.value = true
}
suspend fun onDragEnd(){
isDragging.value = false
isAnimating.value = true
if (totalDragAmount.value > collapseThreshold.value) {
onChangeBottomSheetState(Collapsed)
} else if (totalDragAmount.value < -collapseThreshold.value) {
onChangeBottomSheetState(Expanded)
}
offset.value = Animatable(
Offset(
0f,
sheetPosition.value
),
Offset.VectorConverter
)
offset.value?.animateTo(
targetValue = Offset(
0f,
if (state.value == Expanded)
screenHeight.value - (bottomContainerHeight.value + titleContainerHeight.value)
else screenHeight.value - titleContainerHeight.value
),
animationSpec = tween(
durationMillis = ANIMATION_DURATION,
easing = FastOutSlowInEasing
)
)
isAnimating.value = false
}
/**
* Must call setup before using
*/
fun setup(screenHeight: Float) {
this.screenHeight.value = screenHeight
sheetPosition.value = screenHeight - titleContainerHeight.value
}
fun onChangeBottomSheetState(state: SimpleBottomSheetState){
this.state.value = state
}
fun toggle(){
if(state.value == Collapsed){
state.value = Expanded
}
else{
state.value = Collapsed
}
}
companion object{
const val ANIMATION_DURATION = 500
}
}
sealed class SimpleBottomSheetState {
object Expanded: SimpleBottomSheetState()
object Collapsed: SimpleBottomSheetState()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment