Featured image of post Jetpack Compose 实现 BottomSheetDialog

Jetpack Compose 实现 BottomSheetDialog

从官方BottomSheetScaffold的实现中抄一份出来稍作改动实现和Dialog相同使用体验的BottomSheetDialog

封面来源 Unsplash

引入

Compose 其实是提供了 BottomSheet 的实现方式的,就是用官方的 BottomSheetScaffold ,但是却没有实现 BottomSheetDialog ,导致想要像 Dialog 那样使用 BottomSheetDialog 难以实现,每次都要在外面套一层 BottomSheetScaffold 大大加重了心智负担与嵌套复杂性。因此我将官方的 BottomSheet 实现直接全部 Copy 出来了一份稍作修改,实现独立的 BottomSheetDialog。

效果图

按规矩先上效果图: 图源App:BBHUST

以下是文件结构:

sheet
    ├── BottomSheetDialog.kt
    ├── BottomSheetDialogState.kt
    └── DialogSwipeableState.kt

基本上所有代码都是从官方实现里拷贝出来的,只做了一些小的修改

具体代码

// BottomSheetDialog.kt
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.material.BottomSheetScaffoldDefaults
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BottomSheetDialog(
	modifier: Modifier = Modifier,
	bottomSheetDialogState: BottomSheetDialogState,
	sheetGesturesEnabled: Boolean = true,
	sheetShape: Shape = MaterialTheme.shapes.large,
	sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation,
	sheetBackgroundColor: Color = MaterialTheme.colors.surface,
	sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
	sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight,
	sheetContent: @Composable ColumnScope.() -> Unit,
) {
	val scope = rememberCoroutineScope()
	BoxWithConstraints(modifier) {
		val fullHeight = constraints.maxHeight.toFloat()
		val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() }
		var bottomSheetHeight by remember { mutableStateOf(fullHeight) }
		val swipeable = Modifier
            .nestedScroll(bottomSheetDialogState.nestedScrollConnection)
            .dialogSwipeable(
                state = bottomSheetDialogState,
                anchors = mapOf(
                    fullHeight to BottomSheetDialogValue.Hide,//隐藏状态时顶部Padding为fullHeight
                    fullHeight - peekHeightPx to BottomSheetDialogValue.Collapsed,
                    fullHeight - bottomSheetHeight to BottomSheetDialogValue.Expanded
                ),
                orientation = Orientation.Vertical,
                enabled = sheetGesturesEnabled,
                resistance = null
            )
            .semantics {
                if (peekHeightPx != bottomSheetHeight) {
                    if (bottomSheetDialogState.isCollapsed) {
                        expand {
                            if (bottomSheetDialogState.confirmStateChange(BottomSheetDialogValue.Expanded)) {
                                scope.launch { bottomSheetDialogState.expand() }
                            }
                            true
                        }
                    } else {
                        collapse {
                            if (bottomSheetDialogState.confirmStateChange(BottomSheetDialogValue.Collapsed)) {
                                scope.launch { bottomSheetDialogState.collapse() }
                            }
                            true
                        }
                    }
                }
            }
		Layout(
			content = {
				Surface(
                    swipeable
                        .fillMaxWidth()
                        .requiredHeightIn(min = sheetPeekHeight)
                        .onGloballyPositioned {
                            bottomSheetHeight = it.size.height.toFloat()
                        },
					shape = sheetShape,
					elevation = sheetElevation,
					color = sheetBackgroundColor,
					contentColor = sheetContentColor,
					content = { Column(content = sheetContent) }
				)
			}
		) { measurables, constraints ->
			layout(constraints.maxWidth, constraints.maxHeight) {
				val sheetPlaceable =
					measurables.first().measure(constraints.copy(minWidth = 0, minHeight = 0))
				val sheetOffsetY = bottomSheetDialogState.offset.value.roundToInt()
				sheetPlaceable.placeRelative(0, sheetOffsetY)
			}
		}
	}
}

enum class BottomSheetDialogValue {
	Hide,
    //这里增加了一个隐藏的枚举
    //官方的实现是只有折叠和展开两种状态的,没有隐藏状态
    //因此我增加了一个隐藏的实现,配合官方实现只需要改动两个位置(这里的枚举和上面的注释处)就可以实现
    //不得不说官方实现的扩展性确实很强
	Collapsed,
	Expanded
}
// BottomSheetDialogState.kt
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.SwipeableDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable


@Stable
class BottomSheetDialogStateDialog(
	initialValue: BottomSheetDialogValue,
	animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
	confirmStateChange: (BottomSheetDialogValue) -> Boolean = { true }
) : DialogSwipeableState<BottomSheetDialogValue>(
	initialValue = initialValue,
	animationSpec = animationSpec,
	confirmStateChange = confirmStateChange
) {
	/**
	 * Whether the bottom sheet is expanded.
	 */
	val isExpanded: Boolean
		get() = currentValue == BottomSheetDialogValue.Expanded
	
	/**
	 * Whether the bottom sheet is collapsed.
	 */
	val isCollapsed: Boolean
		get() = currentValue == BottomSheetDialogValue.Collapsed
	
	/**
	 * Expand the bottom sheet with animation and suspend until it if fully expanded or animation
	 * has been cancelled. This method will throw [CancellationException] if the animation is
	 * interrupted
	 *
	 * @return the reason the expand animation ended
	 */
	suspend fun expand() = animateTo(BottomSheetDialogValue.Expanded)
	
	/**
	 * Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation
	 * has been cancelled. This method will throw [CancellationException] if the animation is
	 * interrupted
	 *
	 * @return the reason the collapse animation ended
	 */
	suspend fun collapse() = animateTo(BottomSheetDialogValue.Collapsed)
	
	companion object {
		/**
		 * The default [Saver] implementation for [BottomSheetDialogStateDialog].
		 */
		@OptIn(ExperimentalMaterialApi::class)
		fun Saver(
			animationSpec: AnimationSpec<Float>,
			confirmStateChange: (BottomSheetDialogValue) -> Boolean
		): Saver<BottomSheetDialogStateDialog, *> = Saver(save = { it.currentValue }, restore = {
			BottomSheetDialogStateDialog(
				initialValue = it,
				animationSpec = animationSpec,
				confirmStateChange = confirmStateChange
			)
		})
	}
	
	internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
}

/**
 * Create a [BottomSheetDialogStateDialog] and [remember] it.
 *
 * @param initialValue The initial value of the state.
 * @param animationSpec The default animation that will be used to animate to a new state.
 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
 */
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun rememberBottomSheetDialogState(
	initialValue: BottomSheetDialogValue,
	animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
	confirmStateChange: (BottomSheetDialogValue) -> Boolean = { true }
): BottomSheetDialogStateDialog {
	return rememberSaveable(
		animationSpec, saver = BottomSheetDialogStateDialog.Saver(
			animationSpec = animationSpec, confirmStateChange = confirmStateChange
		)
	) {
		BottomSheetDialogStateDialog(
			initialValue = initialValue,
			animationSpec = animationSpec,
			confirmStateChange = confirmStateChange
		)
	}
}
//DialogSwipeableState.kt
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FixedThreshold
import androidx.compose.material.ResistanceConfig
import androidx.compose.material.SwipeProgress
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.SwipeableDefaults.AnimationSpec
import androidx.compose.material.ThresholdConfig
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign

@OptIn(ExperimentalMaterialApi::class)
@Stable

open class DialogSwipeableState<T>(
	initialValue: T,
	internal val animationSpec: AnimationSpec<Float> = AnimationSpec,
	internal val confirmStateChange: (newValue: T) -> Boolean = { true }
) {
	/**
	 * The current value of the state.
	 *
	 * If no swipe or animation is in progress, this corresponds to the anchor at which the
	 * [dialogSwipeable] is currently settled. If a swipe or animation is in progress, this corresponds
	 * the last anchor at which the [dialogSwipeable] was settled before the swipe or animation started.
	 */
	var currentValue: T by mutableStateOf(initialValue)
		private set
	
	/**
	 * Whether the state is currently animating.
	 */
	var isAnimationRunning: Boolean by mutableStateOf(false)
		private set
	
	/**
	 * The current position (in pixels) of the [dialogSwipeable].
	 *
	 * You should use this state to offset your content accordingly. The recommended way is to
	 * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled.
	 */
	val offset: State<Float> get() = offsetState
	
	/**
	 * The amount by which the [dialogSwipeable] has been swiped past its bounds.
	 */
	val overflow: State<Float> get() = overflowState
	
	// Use `Float.NaN` as a placeholder while the state is uninitialised.
	private val offsetState = mutableStateOf(0f)
	private val overflowState = mutableStateOf(0f)
	
	// the source of truth for the "real"(non ui) position
	// basically position in bounds + overflow
	private val absoluteOffset = mutableStateOf(0f)
	
	// current animation target, if animating, otherwise null
	private val animationTarget = mutableStateOf<Float?>(null)
	
	internal var anchors by mutableStateOf(emptyMap<Float, T>())
	
	private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
		snapshotFlow { anchors }
			.filter { it.isNotEmpty() }
			.take(1)
	
	internal var minBound = Float.NEGATIVE_INFINITY
	internal var maxBound = Float.POSITIVE_INFINITY
	
	internal fun ensureInit(newAnchors: Map<Float, T>) {
		if (anchors.isEmpty()) {
			// need to do initial synchronization synchronously :(
			val initialOffset = newAnchors.getOffset(currentValue)
			requireNotNull(initialOffset) {
				"The initial value must have an associated anchor."
			}
			offsetState.value = initialOffset
			absoluteOffset.value = initialOffset
		}
	}
	
	internal suspend fun processNewAnchors(
		oldAnchors: Map<Float, T>,
		newAnchors: Map<Float, T>
	) {
		if (oldAnchors.isEmpty()) {
			// If this is the first time that we receive anchors, then we need to initialise
			// the state so we snap to the offset associated to the initial value.
			minBound = newAnchors.keys.minOrNull()!!
			maxBound = newAnchors.keys.maxOrNull()!!
			val initialOffset = newAnchors.getOffset(currentValue)
			requireNotNull(initialOffset) {
				"The initial value must have an associated anchor."
			}
			snapInternalToOffset(initialOffset)
		} else if (newAnchors != oldAnchors) {
			// If we have received new anchors, then the offset of the current value might
			// have changed, so we need to animate to the new offset. If the current value
			// has been removed from the anchors then we animate to the closest anchor
			// instead. Note that this stops any ongoing animation.
			minBound = Float.NEGATIVE_INFINITY
			maxBound = Float.POSITIVE_INFINITY
			val animationTargetValue = animationTarget.value
			// if we're in the animation already, let's find it a new home
			val targetOffset = if (animationTargetValue != null) {
				// first, try to map old state to the new state
				val oldState = oldAnchors[animationTargetValue]
				val newState = newAnchors.getOffset(oldState)
				// return new state if exists, or find the closes one among new anchors
				newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
			} else {
				// we're not animating, proceed by finding the new anchors for an old value
				val actualOldValue = oldAnchors[offset.value]
				val value = if (actualOldValue == currentValue) currentValue else actualOldValue
				newAnchors.getOffset(value) ?: newAnchors
					.keys.minByOrNull { abs(it - offset.value) }!!
			}
			try {
				animateInternalToOffset(targetOffset, animationSpec)
			} catch (c: CancellationException) {
				// If the animation was interrupted for any reason, snap as a last resort.
				snapInternalToOffset(targetOffset)
			} finally {
				currentValue = newAnchors.getValue(targetOffset)
				minBound = newAnchors.keys.minOrNull()!!
				maxBound = newAnchors.keys.maxOrNull()!!
			}
		}
	}
	
	internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
	
	internal var velocityThreshold by mutableStateOf(0f)
	
	internal var resistance: ResistanceConfig? by mutableStateOf(null)
	
	internal val draggableState = DraggableState {
		val newAbsolute = absoluteOffset.value + it
		val clamped = newAbsolute.coerceIn(minBound, maxBound)
		val overflow = newAbsolute - clamped
		val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
		offsetState.value = clamped + resistanceDelta
		overflowState.value = overflow
		absoluteOffset.value = newAbsolute
	}
	
	private suspend fun snapInternalToOffset(target: Float) {
		draggableState.drag {
			dragBy(target - absoluteOffset.value)
		}
	}
	
	private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
		draggableState.drag {
			var prevValue = absoluteOffset.value
			animationTarget.value = target
			isAnimationRunning = true
			try {
				Animatable(prevValue).animateTo(target, spec) {
					dragBy(this.value - prevValue)
					prevValue = this.value
				}
			} finally {
				animationTarget.value = null
				isAnimationRunning = false
			}
		}
	}
	
	/**
	 * The target value of the state.
	 *
	 * If a swipe is in progress, this is the value that the [dialogSwipeable] would animate to if the
	 * swipe finished. If an animation is running, this is the target value of that animation.
	 * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
	 */
	
	val targetValue: T
		get() {
			// TODO(calintat): Track current velocity (b/149549482) and use that here.
			val target = animationTarget.value ?: computeTarget(
				offset = offset.value,
				lastValue = anchors.getOffset(currentValue) ?: offset.value,
				anchors = anchors.keys,
				thresholds = thresholds,
				velocity = 0f,
				velocityThreshold = Float.POSITIVE_INFINITY
			)
			return anchors[target] ?: currentValue
		}
	
	/**
	 * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
	 *
	 * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
	 */
	
	val progress: SwipeProgress<T>
		get() {
			val bounds = findBounds(offset.value, anchors.keys)
			val from: T
			val to: T
			val fraction: Float
			when (bounds.size) {
				0 -> {
					from = currentValue
					to = currentValue
					fraction = 1f
				}
				
				1 -> {
					from = anchors.getValue(bounds[0])
					to = anchors.getValue(bounds[0])
					fraction = 1f
				}
				
				else -> {
					val (a, b) =
						if (direction > 0f) {
							bounds[0] to bounds[1]
						} else {
							bounds[1] to bounds[0]
						}
					from = anchors.getValue(a)
					to = anchors.getValue(b)
					fraction = (offset.value - a) / (b - a)
				}
			}
			return SwipeProgress(from, to, fraction)
		}
	
	/**
	 * The direction in which the [dialogSwipeable] is moving, relative to the current [currentValue].
	 *
	 * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
	 * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
	 */
	
	val direction: Float
		get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
	
	/**
	 * Set the state without any animation and suspend until it's set
	 *
	 * @param targetValue The new target value to set [currentValue] to.
	 */
	
	suspend fun snapTo(targetValue: T) {
		latestNonEmptyAnchorsFlow.collect { anchors ->
			val targetOffset = anchors.getOffset(targetValue)
			requireNotNull(targetOffset) {
				"The target value must have an associated anchor."
			}
			snapInternalToOffset(targetOffset)
			currentValue = targetValue
		}
	}
	
	/**
	 * Set the state to the target value by starting an animation.
	 *
	 * @param targetValue The new value to animate to.
	 * @param anim The animation that will be used to animate to the new value.
	 */
	
	suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
		latestNonEmptyAnchorsFlow.collect { anchors ->
			try {
				val targetOffset = anchors.getOffset(targetValue)
				requireNotNull(targetOffset) {
					"The target value must have an associated anchor."
				}
				animateInternalToOffset(targetOffset, anim)
			} finally {
				val endOffset = absoluteOffset.value
				val endValue = anchors
					// fighting rounding error once again, anchor should be as close as 0.5 pixels
					.filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
					.values.firstOrNull() ?: currentValue
				currentValue = endValue
			}
		}
	}
	
	/**
	 * Perform fling with settling to one of the anchors which is determined by the given
	 * [velocity]. Fling with settling [dialogSwipeable] will always consume all the velocity provided
	 * since it will settle at the anchor.
	 *
	 * In general cases, [dialogSwipeable] flings by itself when being swiped. This method is to be
	 * used for nested scroll logic that wraps the [dialogSwipeable]. In nested scroll developer may
	 * want to trigger settling fling when the child scroll container reaches the bound.
	 *
	 * @param velocity velocity to fling and settle with
	 *
	 * @return the reason fling ended
	 */
	suspend fun performFling(velocity: Float) {
		latestNonEmptyAnchorsFlow.collect { anchors ->
			val lastAnchor = anchors.getOffset(currentValue)!!
			val targetValue = computeTarget(
				offset = offset.value,
				lastValue = lastAnchor,
				anchors = anchors.keys,
				thresholds = thresholds,
				velocity = velocity,
				velocityThreshold = velocityThreshold
			)
			val targetState = anchors[targetValue]
			if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
			// If the user vetoed the state change, rollback to the previous state.
			else animateInternalToOffset(lastAnchor, animationSpec)
		}
	}
	
	/**
	 * Force [dialogSwipeable] to consume drag delta provided from outside of the regular [dialogSwipeable]
	 * gesture flow.
	 *
	 * Note: This method performs generic drag and it won't settle to any particular anchor, *
	 * leaving swipeable in between anchors. When done dragging, [performFling] must be
	 * called as well to ensure swipeable will settle at the anchor.
	 *
	 * In general cases, [dialogSwipeable] drags by itself when being swiped. This method is to be
	 * used for nested scroll logic that wraps the [dialogSwipeable]. In nested scroll developer may
	 * want to force drag when the child scroll container reaches the bound.
	 *
	 * @param delta delta in pixels to drag by
	 *
	 * @return the amount of [delta] consumed
	 */
	fun performDrag(delta: Float): Float {
		val potentiallyConsumed = absoluteOffset.value + delta
		val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
		val deltaToConsume = clamped - absoluteOffset.value
		if (abs(deltaToConsume) > 0) {
			draggableState.dispatchRawDelta(deltaToConsume)
		}
		return deltaToConsume
	}
	
	companion object {
		/**
		 * The default [Saver] implementation for [DialogSwipeableState].
		 */
		fun <T : Any> Saver(
			animationSpec: AnimationSpec<Float>,
			confirmStateChange: (T) -> Boolean
		) = Saver<DialogSwipeableState<T>, T>(
			save = { it.currentValue },
			restore = { DialogSwipeableState(it, animationSpec, confirmStateChange) }
		)
	}
}

private fun <T> Map<Float, T>.getOffset(state: T): Float? {
	return entries.firstOrNull { it.value == state }?.key
}

/**
 *  Given an offset x and a set of anchors, return a list of anchors:
 *   1. [ ] if the set of anchors is empty,
 *   2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x'
 *      is x rounded to the exact value of the matching anchor,
 *   3. [ min ] if min is the minimum anchor and x < min,
 *   4. [ max ] if max is the maximum anchor and x > max, or
 *   5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
 */
private fun findBounds(
	offset: Float,
	anchors: Set<Float>
): List<Float> {
	// Find the anchors the target lies between with a little bit of rounding error.
	val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
	val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
	
	return when {
		a == null ->
			// case 1 or 3
			listOfNotNull(b)
		
		b == null ->
			// case 4
			listOf(a)
		
		a == b ->
			// case 2
			// Can't return offset itself here since it might not be exactly equal
			// to the anchor, despite being considered an exact match.
			listOf(a)
		
		else ->
			// case 5
			listOf(a, b)
	}
}

private fun computeTarget(
	offset: Float,
	lastValue: Float,
	anchors: Set<Float>,
	thresholds: (Float, Float) -> Float,
	velocity: Float,
	velocityThreshold: Float
): Float {
	val bounds = findBounds(offset, anchors)
	return when (bounds.size) {
		0 -> lastValue
		1 -> bounds[0]
		else -> {
			val lower = bounds[0]
			val upper = bounds[1]
			if (lastValue <= offset) {
				// Swiping from lower to upper (positive).
				if (velocity >= velocityThreshold) {
					return upper
				} else {
					val threshold = thresholds(lower, upper)
					if (offset < threshold) lower else upper
				}
			} else {
				// Swiping from upper to lower (negative).
				if (velocity <= -velocityThreshold) {
					return lower
				} else {
					val threshold = thresholds(upper, lower)
					if (offset > threshold) upper else lower
				}
			}
		}
	}
}


// temp default nested scroll connection for swipeables which desire as an opt in
// revisit in b/174756744 as all types will have their own specific connection probably
internal val <T> DialogSwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
	get() = object : NestedScrollConnection {
		override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
			val delta = available.toFloat()
			return if (delta < 0 && source == NestedScrollSource.Drag) {
				performDrag(delta).toOffset()
			} else {
				Offset.Zero
			}
		}
		
		override fun onPostScroll(
			consumed: Offset,
			available: Offset,
			source: NestedScrollSource
		): Offset {
			return if (source == NestedScrollSource.Drag) {
				performDrag(available.toFloat()).toOffset()
			} else {
				Offset.Zero
			}
		}
		
		override suspend fun onPreFling(available: Velocity): Velocity {
			val toFling = Offset(available.x, available.y).toFloat()
			return if (toFling < 0 && offset.value > minBound) {
				performFling(velocity = toFling)
				// since we go to the anchor with tween settling, consume all for the best UX
				available
			} else {
				Velocity.Zero
			}
		}
		
		override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
			performFling(velocity = Offset(available.x, available.y).toFloat())
			return available
		}
		
		private fun Float.toOffset(): Offset = Offset(0f, this)
		
		private fun Offset.toFloat(): Float = this.y
	}


@OptIn(ExperimentalMaterialApi::class)
fun <T> Modifier.dialogSwipeable(
	state: DialogSwipeableState<T>,
	anchors: Map<Float, T>,
	orientation: Orientation,
	enabled: Boolean = true,
	reverseDirection: Boolean = false,
	interactionSource: MutableInteractionSource? = null,
	thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
	resistance: ResistanceConfig? = SwipeableDefaults.resistanceConfig(anchors.keys),
	velocityThreshold: Dp = SwipeableDefaults.VelocityThreshold
) = composed(
	inspectorInfo = debugInspectorInfo {
		name = "swipeable"
		properties["state"] = state
		properties["anchors"] = anchors
		properties["orientation"] = orientation
		properties["enabled"] = enabled
		properties["reverseDirection"] = reverseDirection
		properties["interactionSource"] = interactionSource
		properties["thresholds"] = thresholds
		properties["resistance"] = resistance
		properties["velocityThreshold"] = velocityThreshold
	}
) {
	require(anchors.isNotEmpty()) {
		"You must have at least one anchor."
	}
	require(anchors.values.distinct().count() == anchors.size) {
		"You cannot have two anchors mapped to the same state."
	}
	val density = LocalDensity.current
	state.ensureInit(anchors)
	LaunchedEffect(anchors, state) {
		val oldAnchors = state.anchors
		state.anchors = anchors
		state.resistance = resistance
		state.thresholds = { a, b ->
			val from = anchors.getValue(a)
			val to = anchors.getValue(b)
			with(thresholds(from, to)) { density.computeThreshold(a, b) }
		}
		with(density) {
			state.velocityThreshold = velocityThreshold.toPx()
		}
		state.processNewAnchors(oldAnchors, anchors)
	}
	
	Modifier.draggable(
		orientation = orientation,
		enabled = enabled,
		reverseDirection = reverseDirection,
		interactionSource = interactionSource,
		startDragImmediately = state.isAnimationRunning,
		onDragStopped = { velocity -> launch { state.performFling(velocity) } },
		state = state.draggableState
	)
}

使用方法

使用方法与普通的 Dialog 使用方法一致,如下:

val state = rememberBottomSheetDialogState(initialValue = BottomSheetDialogValue.Collapsed)//初始状态,这里设为折叠
BottomSheetDialog(
	modifier = Modifier.systemBarsPadding(),
    // Modifier 设置一下系统栏边距,防止展开的时候跑到状态栏内了
	bottomSheetDialogState = state,
	sheetPeekHeight = 200.dp,//折叠状态下高度
	sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),//Sheet形状,这里设置顶部两个小圆角
	sheetBackgroundColor = Color.White,//背景颜色
	sheetGesturesEnabled = true,//是否允许用户拖拽
	sheetElevation = 0.dp//设置Sheet阴影高度
	) {
		//BottomSheet 内的内容
	}