Skip to content

Instantly share code, notes, and snippets.

@GrJanKandrac
Created September 14, 2023 09:04
Show Gist options
  • Save GrJanKandrac/8e28e8ee1db35bad883c33bd664d0f5f to your computer and use it in GitHub Desktop.
Save GrJanKandrac/8e28e8ee1db35bad883c33bd664d0f5f to your computer and use it in GitHub Desktop.
Bottom sheet with multiple anchors and content visible above sheet
package ui.mab
import android.os.Parcelable
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import kotlin.math.roundToInt
/**
* @param initialValue is preferred initial value (0 means hidden) - can be suppressed if not possible
*/
@OptIn(ExperimentalMaterialApi::class)
class MultiAnchorSheetState(
val initialValue : Anchor = Anchor.Hidden,
) : SwipeableState<Anchor>(initialValue, SwipeableDefaults.AnimationSpec) {
suspend fun show(anchor: Anchor = Anchor.Shown) = animateTo(anchor)
suspend fun hide() = animateTo(initialValue)
}
@Composable
fun rememberMultiAnchorState(initialValue: Anchor = Anchor.Hidden): MultiAnchorSheetState =
rememberSaveable(
init = { MultiAnchorSheetState(initialValue) },
saver = Saver(
save = { it.currentValue },
restore = ::MultiAnchorSheetState
)
)
sealed interface Anchor: Parcelable {
@Parcelize data class Dips(val dpValue: Float) : Anchor
@Parcelize data object Hidden : Anchor
@Parcelize data object AboveContentOnly : Anchor
@Parcelize data object Shown : Anchor
}
/**
* @param modifier applied for whole content including [content] and [aboveContent]
* @param state
* @param anchors to be applied
* @param showScrim whether or not to dim background
* @param onCloseAction
* @param aboveContent content shown above bottom sheet
* @param content content shown inside bottom sheet
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MultiAnchorSheet(
modifier : Modifier = Modifier,
state : MultiAnchorSheetState,
sheetColor : Color,
anchors : List<Anchor> = listOf(Anchor.Hidden, Anchor.Shown),
showScrim : Boolean = true,
onCloseAction : () -> Unit = {},
aboveContent : @Composable (scrollProgress: Float) -> Unit = {},
content : @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val sheetHeight = remember { mutableStateOf<Float?>(null) }
val aboveContentHeight = remember { mutableFloatStateOf(0f) }
BackHandler(state.currentValue != state.initialValue) {
scope.launch { state.hide(); onCloseAction() }
}
if (showScrim) {
Scrim(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
onDismiss = { scope.launch { state.hide(); onCloseAction() } },
visible = state.targetValue != state.initialValue)
}
BoxWithConstraints {
Box(Modifier
.fillMaxWidth()
.offset {
IntOffset(0, if (sheetHeight.value == null) this@BoxWithConstraints.maxHeight.toPx().roundToInt()
else { state.offset.value.roundToInt() })
}
.then(modifier),
content = {
Column {
Box(modifier = Modifier.onSizeChanged { aboveContentHeight.floatValue = it.height.toFloat() }) {
aboveContent(1f - state.offset.value / (sheetHeight.value ?: 1f))
}
Surface(Modifier
.fillMaxWidth()
.onGloballyPositioned { sheetHeight.value = it.size.height.toFloat() },
shape = RoundedCornerShape(topEnd = 16.dp, topStart = 16.dp),
elevation = 16.dp,
color = sheetColor,
) {
Column(
Modifier
.nestedScroll(state.NestedScrollConnection)
.bottomSheetSwipeable(
density = LocalDensity.current,
sheetState = state,
totalHeight = with(LocalDensity.current) { this@BoxWithConstraints.maxHeight.toPx() },
sheetHeight = sheetHeight.value,
aboveContentHeight = aboveContentHeight.floatValue,
anchors = anchors
)
) {
content()
}
}
}
}
)
}
}
/**
* @param totalHeight is pixel height that this bottom sheet can take
* @param sheetHeight is pixel height of sheet content (can be lower than [totalHeight])
*/
@OptIn(ExperimentalMaterialApi::class)
private fun Modifier.bottomSheetSwipeable(
density : Density,
sheetState : MultiAnchorSheetState,
totalHeight : Float,
sheetHeight : Float?,
aboveContentHeight : Float,
anchors : List<Anchor>
): Modifier {
if (sheetHeight == null) return Modifier
// map anchors to pixel values
val result = anchors
.map { anchor ->
when (anchor) {
Anchor.Shown -> (totalHeight - sheetHeight - aboveContentHeight) to anchor
is Anchor.Dips -> totalHeight - with(density) { anchor.dpValue.dp.toPx() } - aboveContentHeight to anchor
Anchor.Hidden -> totalHeight to anchor
Anchor.AboveContentOnly -> totalHeight - aboveContentHeight to anchor
}
}
.filter { it.first >= 0f }
.toMap()
return this.then(
Modifier.swipeable(
state = sheetState,
anchors = result,
orientation = Orientation.Vertical,
enabled = true,
resistance = null
)
)
}
@Composable
private fun Scrim(
color : Color,
onDismiss : () -> Unit,
visible : Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "Scrim"
)
val dismissModifier = if (visible)
Modifier.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
else
Modifier
androidx.compose.foundation.Canvas(Modifier.fillMaxSize().then(dismissModifier)) {
drawRect(color = color, alpha = alpha)
}
}
}
/**
* @param modifier
* @param state
* @param aboveContent is Composable displayed above bottom sheet. `scrollProgress` = 1f means bottom sheet is fully expanded
* @param content shown inside bottom sheet in Column. Every content has it's own anchor associated
*/
@Composable
fun MultiAnchorBottomSheet(
modifier : Modifier = Modifier,
state : MultiAnchorSheetState,
sheetColor : Color,
anchors : List<Anchor> = listOf(Anchor.Hidden, Anchor.Shown),
aboveContent : @Composable (scrollProgress: Float) -> Unit = {},
content : @Composable () -> Unit
) {
MultiAnchorSheet(
modifier = modifier,
showScrim = false,
state = state,
anchors = anchors,
aboveContent = aboveContent,
content = content,
sheetColor = sheetColor
)
}
@OptIn(ExperimentalMaterialApi::class)
internal val <T> SwipeableState<T>.NestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
return if (delta < 0 && source == NestedScrollSource.Drag)
Offset(0f, performDrag(delta))
else
Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
if (source == NestedScrollSource.Drag)
Offset(0f, performDrag(available.y))
else
Offset.Zero
override suspend fun onPreFling(available: Velocity): Velocity {
return if (available.y < 0) {
performFling(velocity = available.y)
available
} else
Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
performFling(velocity = Offset(available.x, available.y).y)
return available
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment