package com.suno.android.ui.bottom_sheets.create import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollScope import androidx.compose.foundation.gestures.TargetedFlingBehavior import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.suno.android.common_ui.extensions.consumeTouchEvents import com.suno.android.common_ui.theme.ExtendedTheme import kotlin.math.roundToInt enum class CollapsableSheetValue { Collapsed, Expanded, } internal val LocalCreateBottomButtonHeight = compositionLocalOf { 0.dp } val WindowInsets.Companion.createBottomButtons @Composable get() = WindowInsets( bottom = LocalCreateBottomButtonHeight.current, ) @Stable class CollapsableSheetState( val anchoredDraggableState: AnchoredDraggableState, val flingBehavior: TargetedFlingBehavior, ) { private var hasScrolledContent = false private var hasMovedSheet = false private var isGestureActive = false suspend fun expand() { anchoredDraggableState.animateTo(CollapsableSheetValue.Expanded) } suspend fun collapse() { anchoredDraggableState.animateTo(CollapsableSheetValue.Collapsed) } val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll( available: Offset, source: NestedScrollSource, ): Offset { // Detect start of new gesture and reset flags if (!isGestureActive) { hasScrolledContent = false hasMovedSheet = false isGestureActive = true } return super.onPreScroll(available, source) } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { // Track if we've scrolled the content in this gesture // Mark as true if any scroll was consumed by the content if (consumed.y != 0f) { hasScrolledContent = true } val remaining = available.y if (remaining != 0f && source == NestedScrollSource.UserInput) { // Only allow sheet to move if we haven't scrolled content yet // or if we've already started moving the sheet in this gesture if (!hasScrolledContent || hasMovedSheet) { val consumed = anchoredDraggableState.dispatchRawDelta(remaining) if (consumed != 0f) { hasMovedSheet = true } return Offset(0f, consumed) } } return super.onPostScroll(consumed, available, source) } override suspend fun onPreFling( available: Velocity, ): Velocity { // Prevent fling if we scrolled content but never moved the sheet if (hasScrolledContent && !hasMovedSheet) { return Velocity.Zero } return super.onPreFling(available) } override suspend fun onPostFling( consumed: Velocity, available: Velocity, ): Velocity { if (!hasScrolledContent || hasMovedSheet) { // borrowed from androidx/compose/foundation/samples/AnchoredDraggableSample.kt anchoredDraggableState.anchoredDrag { // The ScrollScope's lifecycle is tied to the AnchoredDragScope we receive // from anchoredDrag. It is used to bridge AnchoredDraggable and // FlingBehavior. val scrollFlingScope = object : ScrollScope { override fun scrollBy( pixels: Float, ): Float { dragTo(anchoredDraggableState.offset + pixels) return pixels } } // Perform a fling with the fling behavior and scroll scope with(flingBehavior) { scrollFlingScope.performFling(available.y) } } } hasScrolledContent = false hasMovedSheet = false isGestureActive = false return available } } } @Composable fun rememberCollapsableSheetState( initialValue: CollapsableSheetValue = CollapsableSheetValue.Collapsed, ): CollapsableSheetState { val anchoredDraggableState = remember { AnchoredDraggableState( initialValue = initialValue, ) } val flingBehavior = AnchoredDraggableDefaults.flingBehavior(anchoredDraggableState) return remember { // todo rememeberSaveable CollapsableSheetState( anchoredDraggableState = anchoredDraggableState, flingBehavior = flingBehavior, ) } } private val SheetDragHandleSize = 48.dp @Composable fun CollapsableSheetScaffold( modifier: Modifier = Modifier, state: CollapsableSheetState = rememberCollapsableSheetState(), sheetColor: @Composable (Float) -> Color = { ExtendedTheme.colors.backgroundSecondary }, windowInsets: WindowInsets = WindowInsets.safeDrawing, sharedBottom: @Composable (Float, Boolean) -> Unit, sheetContent: @Composable (Float) -> Unit, content: @Composable (Float) -> Unit, ) { val anchoredDraggableState = state.anchoredDraggableState val areAnchorsSet = anchoredDraggableState.anchors.let { it.hasPositionFor(CollapsableSheetValue.Collapsed) && it.hasPositionFor(CollapsableSheetValue.Expanded) } val progress = if (areAnchorsSet) { anchoredDraggableState.progress(CollapsableSheetValue.Collapsed, CollapsableSheetValue.Expanded) } else { // todo this should really be null .5f } val window = LocalWindowInfo.current val composeDensity = LocalDensity.current val bottomInset = windowInsets.getBottom(composeDensity) SubcomposeLayout( modifier = modifier, ) { constraints: Constraints -> val sharedBottomExtremes = subcompose("premeasure") { sharedBottom(0f, true) sharedBottom(1f, true) } val sharedBottomConstraints = constraints.copy( minHeight = 0, ) val sharedBottomHeight = sharedBottomExtremes .map { measureable -> measureable.measure(sharedBottomConstraints) } .maxOf { it.height } val maxHeight = if (constraints.hasBoundedHeight) { constraints.maxHeight } else { window.containerSize.height } val sheetContentHeight = maxHeight - windowInsets.getTop(composeDensity) val sheetContentConstraints = constraints.copy( minHeight = sheetContentHeight, maxHeight = sheetContentHeight, ) val sheetContentPlaceable = subcompose("SheetContent") { CompositionLocalProvider( LocalCreateBottomButtonHeight provides sharedBottomHeight.toDp(), ) { BottomSheet( color = sheetColor(progress), modifier = Modifier .anchoredDraggable( state = anchoredDraggableState, orientation = Orientation.Vertical, ) .nestedScroll(connection = state.nestedScrollConnection) .consumeWindowInsets(windowInsets.only(WindowInsetsSides.Top)), ) { sheetContent(progress) } } }.first().measure(sheetContentConstraints) val contentPlaceable = subcompose("Content") { CompositionLocalProvider( LocalCreateBottomButtonHeight provides sharedBottomHeight.toDp() + SheetDragHandleSize, ) { content(progress) } }.first().measure(constraints) val height = maxOf( sheetContentPlaceable.height, contentPlaceable.height, ) anchoredDraggableState.updateAnchors( DraggableAnchors { CollapsableSheetValue.Expanded at windowInsets.getTop(composeDensity).toFloat() CollapsableSheetValue.Collapsed at height - (sharedBottomHeight.toFloat() + SheetDragHandleSize.toPx()) - bottomInset.toFloat() }, ) layout( width = constraints.maxWidth, height = height, ) { contentPlaceable.place(0, 0) val sheetPosition = anchoredDraggableState.offset.takeUnless { it.isNaN() } ?: 0f sheetContentPlaceable.place( x = 0, y = sheetPosition.roundToInt(), ) subcompose("bottom") { Box( modifier = Modifier.consumeTouchEvents(), ) { sharedBottom(progress, false) } }.first().measure(sharedBottomConstraints).place( x = 0, y = height - sharedBottomHeight - bottomInset, ) } } } @Composable private fun BottomSheet( color: Color, modifier: Modifier = Modifier, content: @Composable (ColumnScope.() -> Unit), ) { Surface( shape = RoundedCornerShape( topStart = 48.dp, topEnd = 48.dp, ), color = color, modifier = modifier, ) { Column { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() .height(SheetDragHandleSize), ) { BottomSheetDefaults.DragHandle() } content() } } }