Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save amal/39a7f1cd653d0591ce1d163197b542df to your computer and use it in GitHub Desktop.
Save amal/39a7f1cd653d0591ce1d163197b542df to your computer and use it in GitHub Desktop.
Dynamic non-modal bottom sheet for Compose that fixes existing issues with material3 implementation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.BottomSheetScaffoldState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.Hidden
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
@Composable
fun rememberDynamicSheetState(
initialValue: SheetValue = Hidden,
): DynamicSheetScaffoldState {
val scaffoldState = rememberBottomSheetScaffoldState(
rememberStandardBottomSheetState(
initialValue = initialValue,
skipHiddenState = false,
),
)
val density = LocalDensity.current
val scope = rememberCoroutineScope()
return remember { DynamicSheetScaffoldState(scaffoldState, density, scope) }
}
class DynamicSheetScaffoldState internal constructor(
internal val scaffoldState: BottomSheetScaffoldState,
private val density: Density,
private val scope: CoroutineScope,
) {
internal val sheetState by scaffoldState::bottomSheetState
val snackbarState by scaffoldState::snackbarHostState
val isExpanded by derivedStateOf { sheetState.currentValue == Expanded }
val isVisible by sheetState::isVisible
val isAnimating by derivedStateOf { sheetState.targetValue != sheetState.currentValue }
internal var contentHeight by mutableFloatStateOf(0f)
internal var peekingContentHeight by mutableFloatStateOf(0f)
private var animJob: Job? = null
internal val peekHeight by derivedStateOf {
with(density) { if (sheetState.currentValue != Hidden) peekingContentHeight.toDp() else 0.dp }
}
val expansionProgress by derivedStateOf {
when {
contentHeight == 0f -> 0f
!isAnimating -> when (sheetState.currentValue) {
Hidden, PartiallyExpanded -> 0f
Expanded -> 1f
}
else -> 1f - sheetState.requireOffset() / contentHeight
}
}
internal fun animateTo(value: SheetValue, cancel: Boolean = false): Job {
val previous = animJob
return scope.launch {
if (cancel) previous?.cancelAndJoin() else previous?.join()
if (sheetState.currentValue == value) return@launch
when (value) {
Hidden -> sheetState.hide()
Expanded -> sheetState.expand()
PartiallyExpanded -> sheetState.partialExpand()
}
}.apply {
animJob = this
invokeOnCompletion { animJob = null }
}
}
}
@Composable
fun DynamicSheetScaffold(
state: DynamicSheetScaffoldState,
applyInsets: Boolean,
targetValue: SheetValue?,
onValueChanged: (SheetValue) -> Unit,
peekingContent: @Composable () -> Unit,
expandedContent: @Composable () -> Unit,
scaffoldContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
insets: WindowInsets = WindowInsets.navigationBars,
sheetShape: Shape = RectangleShape,
sheetContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = 0.dp,
sheetShadowElevation: Dp = 0.dp,
sheetDragHandle: @Composable (() -> Unit)? = null,
sheetSwipeEnabled: Boolean = true,
topBar: @Composable (() -> Unit)? = null,
containerColor: Color = Color.Transparent,
contentColor: Color = MaterialTheme.colorScheme.onBackground,
) {
val peekHeight by animateDpAsState(state.peekHeight)
LaunchedEffect(targetValue) {
if (targetValue == null) return@LaunchedEffect
state.animateTo(targetValue).join()
}
val block by rememberUpdatedState(onValueChanged)
LaunchedEffect(state.sheetState.currentValue, state.sheetState.targetValue) {
if (state.isAnimating) return@LaunchedEffect
block(state.sheetState.currentValue)
}
BottomSheetScaffold(
sheetMaxWidth = Dp.Infinity,
modifier = modifier,
sheetShadowElevation = sheetShadowElevation,
sheetShape = sheetShape,
sheetPeekHeight = peekHeight,
sheetContainerColor = sheetContainerColor,
sheetContentColor = sheetContentColor,
sheetTonalElevation = sheetTonalElevation,
sheetDragHandle = sheetDragHandle,
sheetSwipeEnabled = sheetSwipeEnabled,
containerColor = containerColor,
topBar = topBar,
scaffoldState = state.scaffoldState,
contentColor = contentColor,
sheetContent = {
Box(
Modifier
.fillMaxSize()
.onSizeChanged { state.contentHeight = it.height.toFloat() }
) {
androidx.compose.animation.AnimatedVisibility(
visible = (!state.isExpanded || state.isAnimating) && state.isVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.onSizeChanged { state.peekingContentHeight = it.height.toFloat() }
.graphicsLayer { alpha = 1f - state.expansionProgress }
.thenIf(applyInsets) { windowInsetsPadding(insets) }
) {
peekingContent()
}
androidx.compose.animation.AnimatedVisibility(
visible = state.isAnimating || state.isExpanded,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.graphicsLayer { alpha = state.expansionProgress }
.thenIf(applyInsets) { windowInsetsPadding(insets) }
) {
expandedContent()
}
}
}
) {
Box(
Modifier
.padding(it)
.thenIf(applyInsets) { consumeWindowInsets(insets) }
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
scaffoldContent()
}
}
}
inline fun Modifier.thenIf(condition: Boolean, modifier: Modifier.() -> Modifier) =
then(Modifier.Companion.let { if (condition) it.modifier() else it })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment