mirror of https://github.com/tasks/tasks
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
503 lines
18 KiB
Kotlin
503 lines
18 KiB
Kotlin
/*
|
|
* Copyright 2023 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package org.tasks.compose.drawer
|
|
|
|
import androidx.compose.foundation.gestures.Orientation
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
import androidx.compose.foundation.layout.only
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.systemBars
|
|
import androidx.compose.foundation.shape.CornerSize
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ShapeDefaults
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.Stable
|
|
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.RectangleShape
|
|
import androidx.compose.ui.graphics.Shape
|
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.unit.Density
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.Velocity
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.CancellationException
|
|
import org.tasks.compose.drawer.SheetValue.Expanded
|
|
import org.tasks.compose.drawer.SheetValue.Hidden
|
|
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
|
|
|
|
/**
|
|
* State of a sheet composable, such as [ModalBottomSheet]
|
|
*
|
|
* Contains states relating to its swipe position as well as animations between state values.
|
|
*
|
|
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
|
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
|
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
|
* interaction.
|
|
* @param initialValue The initial value of the state.
|
|
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
|
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
|
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
|
* programmatically or by user interaction.
|
|
*/
|
|
@Stable
|
|
@ExperimentalMaterial3Api
|
|
class SheetState @Deprecated(
|
|
message = "This constructor is deprecated. " +
|
|
"Please use the constructor that provides a [Density]",
|
|
replaceWith = ReplaceWith(
|
|
"SheetState(" +
|
|
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
|
|
"confirmValueChange, skipHiddenState)"
|
|
)
|
|
) constructor(
|
|
internal val skipPartiallyExpanded: Boolean,
|
|
initialValue: SheetValue = Hidden,
|
|
confirmValueChange: (SheetValue) -> Boolean = { true },
|
|
internal val skipHiddenState: Boolean = false,
|
|
) {
|
|
|
|
/**
|
|
* State of a sheet composable, such as [ModalBottomSheet]
|
|
*
|
|
* Contains states relating to its swipe position as well as animations between state values.
|
|
*
|
|
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
|
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
|
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
|
* interaction.
|
|
* @param initialValue The initial value of the state.
|
|
* @param density The density that this state can use to convert values to and from dp.
|
|
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
|
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
|
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
|
* programmatically or by user interaction.
|
|
*/
|
|
@ExperimentalMaterial3Api
|
|
@Suppress("Deprecation")
|
|
constructor(
|
|
skipPartiallyExpanded: Boolean,
|
|
density: Density,
|
|
initialValue: SheetValue = Hidden,
|
|
confirmValueChange: (SheetValue) -> Boolean = { true },
|
|
skipHiddenState: Boolean = false,
|
|
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
|
|
this.density = density
|
|
}
|
|
init {
|
|
if (skipPartiallyExpanded) {
|
|
require(initialValue != PartiallyExpanded) {
|
|
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
|
|
"is set to true."
|
|
}
|
|
}
|
|
if (skipHiddenState) {
|
|
require(initialValue != Hidden) {
|
|
"The initial value must not be set to Hidden if skipHiddenState is set to true."
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The current value of the state.
|
|
*
|
|
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
|
|
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
|
|
* was in before the swipe or animation started.
|
|
*/
|
|
|
|
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
|
|
|
|
/**
|
|
* The target value of the bottom sheet state.
|
|
*
|
|
* If a swipe is in progress, this is the value that the sheet would animate to if the
|
|
* swipe finishes. 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: SheetValue get() = anchoredDraggableState.targetValue
|
|
|
|
/**
|
|
* Whether the modal bottom sheet is visible.
|
|
*/
|
|
val isVisible: Boolean
|
|
get() = anchoredDraggableState.currentValue != Hidden
|
|
|
|
/**
|
|
* Require the current offset (in pixels) of the bottom sheet.
|
|
*
|
|
* The offset will be initialized during the first measurement phase of the provided sheet
|
|
* content.
|
|
*
|
|
* These are the phases:
|
|
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
|
|
*
|
|
* During the first composition, an [IllegalStateException] is thrown. In subsequent
|
|
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
|
|
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
|
|
* frame, after layout.
|
|
*
|
|
* @throws IllegalStateException If the offset has not been initialized yet
|
|
*/
|
|
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
|
|
|
/**
|
|
* Whether the sheet has an expanded state defined.
|
|
*/
|
|
|
|
val hasExpandedState: Boolean
|
|
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
|
|
|
|
/**
|
|
* Whether the modal bottom sheet has a partially expanded state defined.
|
|
*/
|
|
val hasPartiallyExpandedState: Boolean
|
|
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
|
|
|
|
/**
|
|
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
|
|
* animation has been cancelled.
|
|
* *
|
|
* @throws [CancellationException] if the animation is interrupted
|
|
*/
|
|
suspend fun expand() {
|
|
anchoredDraggableState.animateTo(Expanded)
|
|
}
|
|
|
|
/**
|
|
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
|
|
* cancelled.
|
|
* @throws [CancellationException] if the animation is interrupted
|
|
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
|
|
*/
|
|
suspend fun partialExpand() {
|
|
check(!skipPartiallyExpanded) {
|
|
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
|
|
" skipPartiallyExpanded to false to use this function."
|
|
}
|
|
animateTo(PartiallyExpanded)
|
|
}
|
|
|
|
/**
|
|
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
|
|
* else [Expanded].
|
|
* @throws [CancellationException] if the animation is interrupted
|
|
*/
|
|
suspend fun show() {
|
|
val targetValue = when {
|
|
hasPartiallyExpandedState -> PartiallyExpanded
|
|
else -> Expanded
|
|
}
|
|
animateTo(targetValue)
|
|
}
|
|
|
|
/**
|
|
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
|
|
* been cancelled.
|
|
* @throws [CancellationException] if the animation is interrupted
|
|
*/
|
|
suspend fun hide() {
|
|
check(!skipHiddenState) {
|
|
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
|
|
" to false to use this function."
|
|
}
|
|
animateTo(Hidden)
|
|
}
|
|
|
|
/**
|
|
* Animate to a [targetValue].
|
|
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
|
|
* [targetValue] without updating the offset.
|
|
*
|
|
* @throws CancellationException if the interaction interrupted by another interaction like a
|
|
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
|
*
|
|
* @param targetValue The target value of the animation
|
|
*/
|
|
internal suspend fun animateTo(
|
|
targetValue: SheetValue,
|
|
velocity: Float = anchoredDraggableState.lastVelocity
|
|
) {
|
|
anchoredDraggableState.animateTo(targetValue, velocity)
|
|
}
|
|
|
|
/**
|
|
* Snap to a [targetValue] without any animation.
|
|
*
|
|
* @throws CancellationException if the interaction interrupted by another interaction like a
|
|
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
|
*
|
|
* @param targetValue The target value of the animation
|
|
*/
|
|
internal suspend fun snapTo(targetValue: SheetValue) {
|
|
anchoredDraggableState.snapTo(targetValue)
|
|
}
|
|
|
|
/**
|
|
* Find the closest anchor taking into account the velocity and settle at it with an animation.
|
|
*/
|
|
internal suspend fun settle(velocity: Float) {
|
|
anchoredDraggableState.settle(velocity)
|
|
}
|
|
|
|
internal var anchoredDraggableState = AnchoredDraggableState(
|
|
initialValue = initialValue,
|
|
animationSpec = AnchoredDraggableDefaults.AnimationSpec,
|
|
confirmValueChange = confirmValueChange,
|
|
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
|
|
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
|
|
)
|
|
|
|
internal val offset: Float? get() = anchoredDraggableState.offset
|
|
|
|
internal var density: Density? = null
|
|
private fun requireDensity() = requireNotNull(density) {
|
|
"SheetState did not have a density attached. Are you using SheetState with " +
|
|
"BottomSheetScaffold or ModalBottomSheet component?"
|
|
}
|
|
|
|
companion object {
|
|
/**
|
|
* The default [Saver] implementation for [SheetState].
|
|
*/
|
|
fun Saver(
|
|
skipPartiallyExpanded: Boolean,
|
|
confirmValueChange: (SheetValue) -> Boolean,
|
|
density: Density
|
|
) = Saver<SheetState, SheetValue>(
|
|
save = { it.currentValue },
|
|
restore = { savedValue ->
|
|
SheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* The default [Saver] implementation for [SheetState].
|
|
*/
|
|
@Deprecated(
|
|
message = "This function is deprecated. Please use the overload where Density is" +
|
|
" provided.",
|
|
replaceWith = ReplaceWith(
|
|
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)"
|
|
)
|
|
)
|
|
@Suppress("Deprecation")
|
|
fun Saver(
|
|
skipPartiallyExpanded: Boolean,
|
|
confirmValueChange: (SheetValue) -> Boolean
|
|
) = Saver<SheetState, SheetValue>(
|
|
save = { it.currentValue },
|
|
restore = { savedValue ->
|
|
SheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Possible values of [SheetState].
|
|
*/
|
|
@ExperimentalMaterial3Api
|
|
enum class SheetValue {
|
|
/**
|
|
* The sheet is not visible.
|
|
*/
|
|
Hidden,
|
|
|
|
/**
|
|
* The sheet is visible at full height.
|
|
*/
|
|
Expanded,
|
|
|
|
/**
|
|
* The sheet is partially visible.
|
|
*/
|
|
PartiallyExpanded,
|
|
}
|
|
|
|
/**
|
|
* Contains the default values used by [ModalBottomSheet] and [BottomSheetScaffold].
|
|
*/
|
|
@Stable
|
|
@ExperimentalMaterial3Api
|
|
object BottomSheetDefaults {
|
|
/** The default shape for bottom sheets in a [Hidden] state. */
|
|
val HiddenShape: Shape
|
|
@Composable get() = RectangleShape
|
|
|
|
/** The default shape for a bottom sheets in [PartiallyExpanded] and [Expanded] states. */
|
|
val ExpandedShape: Shape
|
|
@Composable get() = ShapeDefaults.ExtraLarge
|
|
.copy(bottomStart = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp))
|
|
|
|
/** The default container color for a bottom sheet. */
|
|
val ContainerColor: Color
|
|
@Composable get() = MaterialTheme.colorScheme.surface
|
|
|
|
/** The default elevation for a bottom sheet. */
|
|
val Elevation = 1.dp
|
|
|
|
/** The default color of the scrim overlay for background content. */
|
|
val ScrimColor: Color
|
|
@Composable get() = MaterialTheme.colorScheme.scrim.copy(
|
|
alpha = 0.32f
|
|
)
|
|
|
|
/**
|
|
* The default peek height used by [BottomSheetScaffold].
|
|
*/
|
|
val SheetPeekHeight = 56.dp
|
|
|
|
/**
|
|
* The default max width used by [ModalBottomSheet] and [BottomSheetScaffold]
|
|
*/
|
|
val SheetMaxWidth = 640.dp
|
|
|
|
/**
|
|
* Default insets to be used and consumed by the [ModalBottomSheet] window.
|
|
*/
|
|
val windowInsets: WindowInsets
|
|
@Composable
|
|
get() = WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
|
|
|
|
/**
|
|
* The optional visual marker placed on top of a bottom sheet to indicate it may be dragged.
|
|
*/
|
|
@Composable
|
|
fun DragHandle(
|
|
modifier: Modifier = Modifier,
|
|
width: Dp = 32.dp,
|
|
height: Dp = 4.dp,
|
|
shape: Shape = MaterialTheme.shapes.extraLarge,
|
|
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
.copy(alpha = .4f),
|
|
) {
|
|
Surface(
|
|
modifier = modifier
|
|
.padding(vertical = DragHandleVerticalPadding),
|
|
color = color,
|
|
shape = shape
|
|
) {
|
|
Box(
|
|
Modifier
|
|
.size(
|
|
width = width,
|
|
height = height
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
|
sheetState: SheetState,
|
|
orientation: Orientation,
|
|
onFling: (velocity: Float) -> Unit
|
|
): NestedScrollConnection = object : NestedScrollConnection {
|
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
|
val delta = available.toFloat()
|
|
return if (delta < 0 && source == NestedScrollSource.Drag) {
|
|
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
|
|
} else {
|
|
Offset.Zero
|
|
}
|
|
}
|
|
|
|
override fun onPostScroll(
|
|
consumed: Offset,
|
|
available: Offset,
|
|
source: NestedScrollSource
|
|
): Offset {
|
|
return if (source == NestedScrollSource.Drag) {
|
|
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
|
|
} else {
|
|
Offset.Zero
|
|
}
|
|
}
|
|
|
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
|
val toFling = available.toFloat()
|
|
val currentOffset = sheetState.requireOffset()
|
|
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
|
|
return if (toFling < 0 && currentOffset > minAnchor) {
|
|
onFling(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 {
|
|
onFling(available.toFloat())
|
|
return available
|
|
}
|
|
|
|
private fun Float.toOffset(): Offset = Offset(
|
|
x = if (orientation == Orientation.Horizontal) this else 0f,
|
|
y = if (orientation == Orientation.Vertical) this else 0f
|
|
)
|
|
|
|
@JvmName("velocityToFloat")
|
|
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
|
|
|
|
@JvmName("offsetToFloat")
|
|
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
|
|
}
|
|
|
|
@Composable
|
|
@ExperimentalMaterial3Api
|
|
internal fun rememberSheetState(
|
|
skipPartiallyExpanded: Boolean = false,
|
|
confirmValueChange: (SheetValue) -> Boolean = { true },
|
|
initialValue: SheetValue = Hidden,
|
|
skipHiddenState: Boolean = false,
|
|
): SheetState {
|
|
|
|
val density = LocalDensity.current
|
|
return rememberSaveable(
|
|
skipPartiallyExpanded, confirmValueChange,
|
|
saver = SheetState.Saver(
|
|
skipPartiallyExpanded = skipPartiallyExpanded,
|
|
confirmValueChange = confirmValueChange,
|
|
density = density
|
|
)
|
|
) {
|
|
SheetState(
|
|
skipPartiallyExpanded,
|
|
density,
|
|
initialValue,
|
|
confirmValueChange,
|
|
skipHiddenState
|
|
)
|
|
}
|
|
}
|
|
|
|
private val DragHandleVerticalPadding = 22.dp
|