Created
September 14, 2023 09:04
-
-
Save GrJanKandrac/8e28e8ee1db35bad883c33bd664d0f5f to your computer and use it in GitHub Desktop.
Bottom sheet with multiple anchors and content visible above sheet
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
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