All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.outsidesource.oskitcompose.popup.BottomSheet.kt Maven / Gradle / Ivy

package com.outsidesource.oskitcompose.popup

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import com.outsidesource.oskitcompose.lib.VarRef
import com.outsidesource.oskitcompose.modifier.OuterShadow
import com.outsidesource.oskitcompose.modifier.outerShadow
import com.outsidesource.oskitcompose.modifier.preventClickPropagationToParent
import kotlinx.coroutines.launch

@Immutable
data class BottomSheetStyles(
    val transitionDuration: Int = 300,
    val scrimColor: Color = Color.Black.copy(alpha = .5f),
    val maxWidth: Dp = 500.dp,
    val shadow: OuterShadow = OuterShadow(
        blur = 11.dp,
        color = Color.Black.copy(alpha = .25f),
        shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
    ),
    val backgroundColor: Color = Color.White,
    val backgroundShape: Shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
    val contentPadding: PaddingValues = PaddingValues(16.dp),
) {
    companion object {
        /**
         * BottomSheetStyles with all content set to unspecified to allow for custom user definition
         */
        val UserDefinedContent = BottomSheetStyles(
            maxWidth = Dp.Unspecified,
            shadow = OuterShadow(blur = 0.dp, color = Color.Transparent),
            backgroundColor = Color.Transparent,
            backgroundShape = RectangleShape,
            contentPadding = PaddingValues(0.dp),
        )
    }
}

/**
 * Creates a fully customizable [BottomSheet]
 *
 * @param isVisible Whether the modal is visible or not
 * @param onDismissRequest Executes when the user performs an action to dismiss the [BottomSheet]
 * @param dismissOnExternalClick calls [onDismissRequest] when clicking on the scrim
 * @param dismissOnBackPress call [onDismissRequest] when pressing escape or back key
 * @param dismissOnSwipe calls [onDismissRequest] when swiping the bottom sheet away
 * @param isFullScreen Utilized in Android and iOS. Specifies whether to draw behind the system bars or not
 * @param styles Styles to modify the look of the [BottomSheet]
 * @param content The content to be displayed inside the popup.
 */
@Composable
fun BottomSheet(
    isVisible: Boolean,
    modifier: Modifier = Modifier,
    onDismissRequest: (() -> Unit)? = null,
    dismissOnExternalClick: Boolean = true,
    dismissOnBackPress: Boolean = true,
    dismissOnSwipe: Boolean = true,
    isFullScreen: Boolean = true,
    onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
    onKeyEvent: (KeyEvent) -> Boolean = { false },
    styles: BottomSheetStyles = remember { BottomSheetStyles() },
    content: @Composable BoxScope.() -> Unit,
) {
    Box {
        val transition = updateTransition(isVisible, label = "background")
        val alpha by transition.animateFloat(
            transitionSpec = { tween(styles.transitionDuration) },
            targetValueByState = { if (it) 1f else 0f },
            label = "AlphaAnimation"
        )

        if (transition.currentState || transition.targetState) {
            KMPPopup(
                popupPositionProvider = BottomSheetPositionProvider,
                isFullScreen = isFullScreen,
                onDismissRequest = onDismissRequest,
                dismissOnBackPress = dismissOnBackPress,
                onKeyEvent = onKeyEvent,
                onPreviewKeyEvent = onPreviewKeyEvent,
                focusable = true,
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .clipToBounds()
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null,
                            onClick = { if (dismissOnExternalClick) onDismissRequest?.invoke() }
                        )
                        .background(color = styles.scrimColor.copy(styles.scrimColor.alpha * alpha)),
                    contentAlignment = Alignment.BottomCenter,
                ) {
                    val density = LocalDensity.current
                    val swipeData = remember { BottomSheetSwipeData() }
                    val dismissData = remember(onDismissRequest, styles.transitionDuration) {
                        BottomSheetDismissData(
                            onDismissRequest = onDismissRequest,
                            transitionDuration = styles.transitionDuration,
                        )
                    }
                    val isDragging by swipeData.isDragging
                    val offset by swipeData.offset
                    val offsetAnim = swipeData.offsetAnim

                    LaunchedEffect(isVisible) {
                        if (isVisible) {
                            offsetAnim.snapTo(swipeData.size.value.height.toFloat())
                            offsetAnim.animateTo(0f, tween(styles.transitionDuration))
                        } else if (!offsetAnim.isRunning) {
                            offsetAnim.animateTo(
                                swipeData.size.value.height.toFloat(),
                                tween(styles.transitionDuration)
                            )
                        }
                    }

                    CompositionLocalProvider(
                        LocalBottomSheetSwipeData provides swipeData,
                        LocalBottomSheetDismissData provides dismissData,
                    ) {
                        Box(
                            modifier = Modifier
                                .preventClickPropagationToParent()
                                .onGloballyPositioned { swipeData.size.value = it.size }
                                .then(if (dismissOnSwipe) Modifier.bottomSheetSwipeToDismiss() else Modifier)
                                .offset(y = with(density) { if (isDragging) offset.toDp() else offsetAnim.value.toDp() })
                                .widthIn(max = styles.maxWidth)
                                .fillMaxWidth()
                                .outerShadow(
                                    blur = styles.shadow.blur,
                                    color = styles.shadow.color,
                                    shape = styles.shadow.shape,
                                    spread = styles.shadow.spread,
                                    offset = styles.shadow.offset,
                                )
                                .background(
                                    styles.backgroundColor,
                                    styles.backgroundShape
                                )
                                .padding(styles.contentPadding)
                                .then(modifier)
                        ) {
                            content()
                        }
                    }
                }
            }
        }
    }
}

/**
 * Use to allow a bottom sheet to be swiped to dismiss. This can be used in place of [shouldDismissOnSwipe] to provide
 * a specific swipe handle to the user.
 */
fun Modifier.bottomSheetSwipeToDismiss() = composed {
    val swipeData = LocalBottomSheetSwipeData.current
    val dismissData = LocalBottomSheetDismissData.current
    var isDragging by swipeData.isDragging
    var offset by swipeData.offset
    val offsetAnim = swipeData.offsetAnim
    val scope = rememberCoroutineScope()
    val velocityTracker = remember { VelocityTracker() }

    pointerInput(Unit) {
        detectDragGestures(
            onDragStart = {
                isDragging = true
                velocityTracker.resetTracking()
            },
            onDrag = { change, delta ->
                velocityTracker.addPointerInputChange(change)
                offset = (offset + delta.y).coerceAtLeast(0f)
            },
            onDragEnd = {
                scope.launch {
                    isDragging = false
                    offsetAnim.snapTo(offset)

                    val velocity = velocityTracker.calculateVelocity().y
                    if (velocity > 3250) {
                        dismissData.onDismissRequest?.invoke()
                        offsetAnim.animateTo(
                            targetValue = swipeData.size.value.height.toFloat(),
                            initialVelocity = velocity,
                        )
                        return@launch
                    } else if (offset > swipeData.size.value.height / 2) {
                        dismissData.onDismissRequest?.invoke()
                        offsetAnim.animateTo(
                            targetValue = swipeData.size.value.height.toFloat(),
                            animationSpec = tween(dismissData.transitionDuration)
                        )
                        return@launch
                    }

                    offset = 0f
                    offsetAnim.animateTo(0f, tween(dismissData.transitionDuration))
                }
            }
        )
    }
}

private val LocalBottomSheetSwipeData = staticCompositionLocalOf { BottomSheetSwipeData() }
private val LocalBottomSheetDismissData = staticCompositionLocalOf { BottomSheetDismissData() }

private data class BottomSheetSwipeData(
    val offset: MutableState = mutableStateOf(0f),
    val offsetAnim: Animatable = Animatable(Float.MAX_VALUE),
    val isDragging: MutableState = mutableStateOf(false),
    val size: VarRef = VarRef(IntSize.Zero),
)

private data class BottomSheetDismissData(
    val onDismissRequest: (() -> Unit)? = null,
    val transitionDuration: Int = 300,
)

private val BottomSheetPositionProvider = object : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize
    ): IntOffset = IntOffset.Zero
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy